Added challenge mode
This commit is contained in:
@ -703,7 +703,12 @@ void TetrisApp::Impl::runLoop()
|
||||
}
|
||||
} else {
|
||||
if (e.key.scancode == SDL_SCANCODE_RETURN || e.key.scancode == SDL_SCANCODE_KP_ENTER || e.key.scancode == SDL_SCANCODE_SPACE) {
|
||||
game->reset(startLevelSelection);
|
||||
if (game->getMode() == GameMode::Challenge) {
|
||||
game->startChallengeRun(1);
|
||||
} else {
|
||||
game->setMode(GameMode::Endless);
|
||||
game->reset(startLevelSelection);
|
||||
}
|
||||
state = AppState::Playing;
|
||||
stateMgr->setState(state);
|
||||
} else if (e.key.scancode == SDL_SCANCODE_ESCAPE) {
|
||||
@ -732,6 +737,14 @@ void TetrisApp::Impl::runLoop()
|
||||
if (menuInput.activated) {
|
||||
switch (*menuInput.activated) {
|
||||
case ui::BottomMenuItem::Play:
|
||||
if (game) game->setMode(GameMode::Endless);
|
||||
startMenuPlayTransition();
|
||||
break;
|
||||
case ui::BottomMenuItem::Challenge:
|
||||
if (game) {
|
||||
game->setMode(GameMode::Challenge);
|
||||
game->startChallengeRun(1);
|
||||
}
|
||||
startMenuPlayTransition();
|
||||
break;
|
||||
case ui::BottomMenuItem::Level:
|
||||
|
||||
@ -51,7 +51,10 @@ namespace {
|
||||
}
|
||||
|
||||
void Game::reset(int startLevel_) {
|
||||
// Standard reset is primarily for endless; Challenge reuses the same pipeline and then
|
||||
// immediately sets up its own level state.
|
||||
std::fill(board.begin(), board.end(), 0);
|
||||
clearAsteroidGrid();
|
||||
std::fill(blockCounts.begin(), blockCounts.end(), 0);
|
||||
bag.clear();
|
||||
_score = 0; _lines = 0; _level = startLevel_; startLevel = startLevel_;
|
||||
@ -59,6 +62,8 @@ void Game::reset(int startLevel_) {
|
||||
_currentCombo = 0;
|
||||
_maxCombo = 0;
|
||||
_comboCount = 0;
|
||||
challengeComplete = false;
|
||||
challengeLevelActive = false;
|
||||
// Initialize gravity using NES timing table (ms per cell by level)
|
||||
gravityMs = gravityMsForLevel(_level, gravityGlobalMultiplier);
|
||||
fallAcc = 0; gameOver=false; paused=false;
|
||||
@ -69,9 +74,209 @@ void Game::reset(int startLevel_) {
|
||||
_pausedTime = 0;
|
||||
_lastPauseStart = 0;
|
||||
hold = Piece{}; hold.type = PIECE_COUNT; canHold=true;
|
||||
refillBag();
|
||||
pieceSequence = 0;
|
||||
spawn();
|
||||
refillBag();
|
||||
pieceSequence = 0;
|
||||
spawn();
|
||||
|
||||
if (mode == GameMode::Challenge) {
|
||||
int lvl = startLevel_ <= 0 ? 1 : startLevel_;
|
||||
startChallengeRun(lvl);
|
||||
}
|
||||
}
|
||||
|
||||
void Game::clearAsteroidGrid() {
|
||||
for (auto &cell : asteroidGrid) {
|
||||
cell.reset();
|
||||
}
|
||||
asteroidsRemainingCount = 0;
|
||||
asteroidsTotalThisLevel = 0;
|
||||
}
|
||||
|
||||
void Game::startChallengeRun(int startingLevel) {
|
||||
mode = GameMode::Challenge;
|
||||
int lvl = std::clamp(startingLevel, 1, ASTEROID_MAX_LEVEL);
|
||||
// Reset all stats and timers like a fresh run
|
||||
_score = 0; _lines = 0; _level = lvl; startLevel = lvl;
|
||||
_tetrisesMade = 0;
|
||||
_currentCombo = 0;
|
||||
_maxCombo = 0;
|
||||
_comboCount = 0;
|
||||
_startTime = SDL_GetPerformanceCounter();
|
||||
_pausedTime = 0;
|
||||
_lastPauseStart = 0;
|
||||
// Reseed challenge RNG so levels are deterministic per run but distinct per session
|
||||
if (challengeSeedBase == 0) {
|
||||
challengeSeedBase = static_cast<uint32_t>(SDL_GetTicks());
|
||||
}
|
||||
challengeRng.seed(challengeSeedBase + static_cast<uint32_t>(lvl));
|
||||
setupChallengeLevel(lvl, false);
|
||||
}
|
||||
|
||||
void Game::beginNextChallengeLevel() {
|
||||
if (mode != GameMode::Challenge || challengeComplete) {
|
||||
return;
|
||||
}
|
||||
int next = challengeLevelIndex + 1;
|
||||
if (next > ASTEROID_MAX_LEVEL) {
|
||||
challengeComplete = true;
|
||||
challengeLevelActive = false;
|
||||
return;
|
||||
}
|
||||
setupChallengeLevel(next, true);
|
||||
}
|
||||
|
||||
void Game::setupChallengeLevel(int level, bool preserveStats) {
|
||||
challengeLevelIndex = std::clamp(level, 1, ASTEROID_MAX_LEVEL);
|
||||
_level = challengeLevelIndex;
|
||||
startLevel = challengeLevelIndex;
|
||||
challengeComplete = false;
|
||||
challengeLevelActive = true;
|
||||
// Refresh deterministic RNG for this level
|
||||
challengeRng.seed(challengeSeedBase + static_cast<uint32_t>(challengeLevelIndex));
|
||||
|
||||
// Optionally reset cumulative stats (new run) or keep them (between levels)
|
||||
if (!preserveStats) {
|
||||
std::fill(blockCounts.begin(), blockCounts.end(), 0);
|
||||
_score = 0;
|
||||
_lines = 0;
|
||||
_tetrisesMade = 0;
|
||||
_currentCombo = 0;
|
||||
_comboCount = 0;
|
||||
_maxCombo = 0;
|
||||
_startTime = SDL_GetPerformanceCounter();
|
||||
_pausedTime = 0;
|
||||
_lastPauseStart = 0;
|
||||
} else {
|
||||
_currentCombo = 0;
|
||||
}
|
||||
|
||||
// Clear playfield and piece state
|
||||
std::fill(board.begin(), board.end(), 0);
|
||||
clearAsteroidGrid();
|
||||
completedLines.clear();
|
||||
hardDropCells.clear();
|
||||
hardDropFxId = 0;
|
||||
fallAcc = 0.0;
|
||||
gameOver = false;
|
||||
paused = false;
|
||||
softDropping = false;
|
||||
hold = Piece{};
|
||||
hold.type = PIECE_COUNT;
|
||||
canHold = true;
|
||||
bag.clear();
|
||||
refillBag();
|
||||
pieceSequence = 0;
|
||||
spawn();
|
||||
|
||||
// Challenge gravity scales upward per level (faster = smaller ms per cell)
|
||||
double baseMs = gravityMsForLevel(0, gravityGlobalMultiplier);
|
||||
double speedFactor = 1.0 + static_cast<double>(challengeLevelIndex) * 0.02;
|
||||
gravityMs = (speedFactor > 0.0) ? (baseMs / speedFactor) : baseMs;
|
||||
|
||||
// Place asteroids for this level
|
||||
placeAsteroidsForLevel(challengeLevelIndex);
|
||||
|
||||
if (levelUpCallback) {
|
||||
levelUpCallback(_level);
|
||||
}
|
||||
}
|
||||
|
||||
AsteroidType Game::chooseAsteroidTypeForLevel(int level) {
|
||||
// Simple weight distribution by level bands
|
||||
int normalWeight = 100;
|
||||
int armoredWeight = 0;
|
||||
int fallingWeight = 0;
|
||||
int coreWeight = 0;
|
||||
|
||||
if (level >= 10) {
|
||||
armoredWeight = 20;
|
||||
normalWeight = 80;
|
||||
}
|
||||
if (level >= 20) {
|
||||
fallingWeight = 20;
|
||||
normalWeight = 60;
|
||||
}
|
||||
if (level >= 40) {
|
||||
fallingWeight = 30;
|
||||
armoredWeight = 25;
|
||||
normalWeight = 45;
|
||||
}
|
||||
if (level >= 60) {
|
||||
coreWeight = 20;
|
||||
fallingWeight = 30;
|
||||
armoredWeight = 25;
|
||||
normalWeight = 25;
|
||||
}
|
||||
|
||||
int total = normalWeight + armoredWeight + fallingWeight + coreWeight;
|
||||
if (total <= 0) return AsteroidType::Normal;
|
||||
std::uniform_int_distribution<int> dist(0, total - 1);
|
||||
int pick = dist(challengeRng);
|
||||
if (pick < normalWeight) return AsteroidType::Normal;
|
||||
pick -= normalWeight;
|
||||
if (pick < armoredWeight) return AsteroidType::Armored;
|
||||
pick -= armoredWeight;
|
||||
if (pick < fallingWeight) return AsteroidType::Falling;
|
||||
return AsteroidType::Core;
|
||||
}
|
||||
|
||||
AsteroidCell Game::makeAsteroidForType(AsteroidType t) const {
|
||||
AsteroidCell cell{};
|
||||
cell.type = t;
|
||||
switch (t) {
|
||||
case AsteroidType::Normal:
|
||||
cell.hitsRemaining = 1;
|
||||
cell.gravityEnabled = false;
|
||||
break;
|
||||
case AsteroidType::Armored:
|
||||
cell.hitsRemaining = 2;
|
||||
cell.gravityEnabled = false;
|
||||
break;
|
||||
case AsteroidType::Falling:
|
||||
cell.hitsRemaining = 2;
|
||||
cell.gravityEnabled = false;
|
||||
break;
|
||||
case AsteroidType::Core:
|
||||
cell.hitsRemaining = 3;
|
||||
cell.gravityEnabled = false;
|
||||
break;
|
||||
}
|
||||
cell.visualState = 0;
|
||||
return cell;
|
||||
}
|
||||
|
||||
void Game::placeAsteroidsForLevel(int level) {
|
||||
int desired = std::clamp(level, 1, ASTEROID_MAX_LEVEL);
|
||||
// Placement window grows upward with level but caps at half board
|
||||
int height = std::clamp(2 + level / 3, 2, ROWS / 2);
|
||||
int minRow = ROWS - 1 - height;
|
||||
int maxRow = ROWS - 1;
|
||||
minRow = std::max(0, minRow);
|
||||
|
||||
std::uniform_int_distribution<int> xDist(0, COLS - 1);
|
||||
std::uniform_int_distribution<int> yDist(minRow, maxRow);
|
||||
|
||||
int attempts = 0;
|
||||
const int maxAttempts = desired * 16;
|
||||
while (asteroidsRemainingCount < desired && attempts < maxAttempts) {
|
||||
int x = xDist(challengeRng);
|
||||
int y = yDist(challengeRng);
|
||||
int idx = y * COLS + x;
|
||||
attempts++;
|
||||
if (board[idx] != 0 || asteroidGrid[idx].has_value()) {
|
||||
continue;
|
||||
}
|
||||
AsteroidType type = chooseAsteroidTypeForLevel(level);
|
||||
AsteroidCell cell = makeAsteroidForType(type);
|
||||
board[idx] = ASTEROID_BASE + static_cast<int>(type);
|
||||
asteroidGrid[idx] = cell;
|
||||
++asteroidsRemainingCount;
|
||||
++asteroidsTotalThisLevel;
|
||||
}
|
||||
|
||||
if (asteroidsRemainingCount < desired) {
|
||||
SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "[CHALLENGE] Placed %d/%d asteroids for level %d", asteroidsRemainingCount, desired, level);
|
||||
}
|
||||
}
|
||||
|
||||
double Game::elapsed() const {
|
||||
@ -235,23 +440,28 @@ void Game::lockPiece() {
|
||||
_tetrisesMade += 1;
|
||||
}
|
||||
|
||||
// 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 (mode != GameMode::Challenge) {
|
||||
// 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 (targetLevel > _level) {
|
||||
_level = targetLevel;
|
||||
// Update gravity to exact NES speed for the new level
|
||||
gravityMs = gravityMsForLevel(_level, gravityGlobalMultiplier);
|
||||
if (levelUpCallback) levelUpCallback(_level);
|
||||
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);
|
||||
}
|
||||
} else {
|
||||
// Challenge keeps level tied to the current challenge stage; gravity already set there
|
||||
_level = challengeLevelIndex;
|
||||
}
|
||||
|
||||
// Trigger sound effect callback for line clears
|
||||
@ -295,30 +505,130 @@ void Game::clearCompletedLines() {
|
||||
|
||||
void Game::actualClearLines() {
|
||||
if (completedLines.empty()) return;
|
||||
|
||||
int write = ROWS - 1;
|
||||
|
||||
std::array<int, COLS*ROWS> newBoard{};
|
||||
std::array<std::optional<AsteroidCell>, COLS*ROWS> newAst{};
|
||||
for (auto &cell : newAst) cell.reset();
|
||||
std::fill(newBoard.begin(), newBoard.end(), 0);
|
||||
|
||||
handleAsteroidsOnClearedRows(completedLines, newBoard, newAst);
|
||||
|
||||
board = newBoard;
|
||||
asteroidGrid = newAst;
|
||||
|
||||
// Apply asteroid-specific gravity after the board collapses
|
||||
applyAsteroidGravity();
|
||||
|
||||
if (mode == GameMode::Challenge) {
|
||||
if (asteroidsRemainingCount <= 0) {
|
||||
beginNextChallengeLevel();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void Game::handleAsteroidsOnClearedRows(const std::vector<int>& clearedRows,
|
||||
std::array<int, COLS*ROWS>& outBoard,
|
||||
std::array<std::optional<AsteroidCell>, COLS*ROWS>& outAsteroids) {
|
||||
std::vector<bool> clearedFlags(ROWS, false);
|
||||
for (int r : clearedRows) {
|
||||
if (r >= 0 && r < ROWS) {
|
||||
clearedFlags[r] = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Track asteroid count updates during processing
|
||||
int destroyedThisPass = 0;
|
||||
|
||||
// Precompute how many cleared rows are at or below each row to reposition survivors
|
||||
std::array<int, ROWS> clearedBelow{};
|
||||
int running = 0;
|
||||
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];
|
||||
clearedBelow[y] = running;
|
||||
if (clearedFlags[y]) {
|
||||
++running;
|
||||
}
|
||||
}
|
||||
|
||||
for (int y = ROWS - 1; y >= 0; --y) {
|
||||
for (int x = 0; x < COLS; ++x) {
|
||||
int srcIdx = y * COLS + x;
|
||||
bool rowCleared = clearedFlags[y];
|
||||
bool isAsteroid = asteroidGrid[srcIdx].has_value();
|
||||
|
||||
if (rowCleared) {
|
||||
if (!isAsteroid) {
|
||||
continue; // normal blocks in cleared rows vanish
|
||||
}
|
||||
|
||||
AsteroidCell cell = *asteroidGrid[srcIdx];
|
||||
if (cell.hitsRemaining > 0) {
|
||||
--cell.hitsRemaining;
|
||||
}
|
||||
if (cell.hitsRemaining == 0) {
|
||||
destroyedThisPass++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Update visual/gravity state for surviving asteroids
|
||||
cell.visualState = static_cast<uint8_t>(std::min<int>(3, cell.visualState + 1));
|
||||
if (cell.type == AsteroidType::Falling || cell.type == AsteroidType::Core) {
|
||||
cell.gravityEnabled = true;
|
||||
}
|
||||
|
||||
int destY = y + clearedBelow[y]; // shift down by cleared rows below
|
||||
if (destY >= ROWS) {
|
||||
continue; // off the board after collapse
|
||||
}
|
||||
int destIdx = destY * COLS + x;
|
||||
outBoard[destIdx] = ASTEROID_BASE + static_cast<int>(cell.type);
|
||||
outAsteroids[destIdx] = cell;
|
||||
} else {
|
||||
int destY = y + clearedBelow[y];
|
||||
if (destY >= ROWS) {
|
||||
continue;
|
||||
}
|
||||
int destIdx = destY * COLS + x;
|
||||
outBoard[destIdx] = board[srcIdx];
|
||||
if (isAsteroid) {
|
||||
outAsteroids[destIdx] = asteroidGrid[srcIdx];
|
||||
}
|
||||
}
|
||||
--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;
|
||||
}
|
||||
}
|
||||
|
||||
if (destroyedThisPass > 0) {
|
||||
asteroidsRemainingCount = std::max(0, asteroidsRemainingCount - destroyedThisPass);
|
||||
}
|
||||
}
|
||||
|
||||
void Game::applyAsteroidGravity() {
|
||||
if (asteroidsRemainingCount <= 0) {
|
||||
return;
|
||||
}
|
||||
bool moved = false;
|
||||
do {
|
||||
moved = false;
|
||||
for (int y = ROWS - 2; y >= 0; --y) {
|
||||
for (int x = 0; x < COLS; ++x) {
|
||||
int idx = y * COLS + x;
|
||||
if (!asteroidGrid[idx].has_value()) {
|
||||
continue;
|
||||
}
|
||||
if (!asteroidGrid[idx]->gravityEnabled) {
|
||||
continue;
|
||||
}
|
||||
int belowIdx = (y + 1) * COLS + x;
|
||||
if (board[belowIdx] == 0) {
|
||||
// Move asteroid down one cell
|
||||
board[belowIdx] = board[idx];
|
||||
asteroidGrid[belowIdx] = asteroidGrid[idx];
|
||||
board[idx] = 0;
|
||||
asteroidGrid[idx].reset();
|
||||
moved = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
} while (moved);
|
||||
}
|
||||
|
||||
bool Game::tryMoveDown() {
|
||||
|
||||
@ -7,12 +7,26 @@
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <SDL3/SDL.h>
|
||||
#include "../../core/GravityManager.h"
|
||||
|
||||
enum PieceType { I, O, T, S, Z, J, L, PIECE_COUNT };
|
||||
using Shape = std::array<uint16_t, 4>; // four rotation bitmasks
|
||||
|
||||
// Game runtime mode
|
||||
enum class GameMode { Endless, Challenge };
|
||||
|
||||
// Special obstacle blocks used by Challenge mode
|
||||
enum class AsteroidType : uint8_t { Normal = 0, Armored = 1, Falling = 2, Core = 3 };
|
||||
|
||||
struct AsteroidCell {
|
||||
AsteroidType type{AsteroidType::Normal};
|
||||
uint8_t hitsRemaining{1};
|
||||
bool gravityEnabled{false};
|
||||
uint8_t visualState{0};
|
||||
};
|
||||
|
||||
class Game {
|
||||
public:
|
||||
static constexpr int COLS = 10;
|
||||
@ -21,8 +35,10 @@ public:
|
||||
|
||||
struct Piece { PieceType type{PIECE_COUNT}; int rot{0}; int x{3}; int y{-2}; };
|
||||
|
||||
explicit Game(int startLevel = 0) { reset(startLevel); }
|
||||
explicit Game(int startLevel = 0, GameMode mode = GameMode::Endless) : mode(mode) { reset(startLevel); }
|
||||
void reset(int startLevel = 0);
|
||||
void startChallengeRun(int startingLevel = 1); // resets stats and starts challenge level 1 (or provided)
|
||||
void beginNextChallengeLevel(); // advances to the next challenge level preserving score/time
|
||||
|
||||
// Simulation -----------------------------------------------------------
|
||||
void tickGravity(double frameMs); // advance gravity accumulator & drop
|
||||
@ -42,13 +58,20 @@ public:
|
||||
bool isGameOver() const { return gameOver; }
|
||||
bool isPaused() const { return paused; }
|
||||
void setPaused(bool p);
|
||||
GameMode getMode() const { return mode; }
|
||||
void setMode(GameMode m) { mode = m; }
|
||||
int score() const { return _score; }
|
||||
int lines() const { return _lines; }
|
||||
int level() const { return _level; }
|
||||
int challengeLevel() const { return challengeLevelIndex; }
|
||||
int asteroidsRemaining() const { return asteroidsRemainingCount; }
|
||||
int asteroidsTotal() const { return asteroidsTotalThisLevel; }
|
||||
bool isChallengeComplete() const { return challengeComplete; }
|
||||
int startLevelBase() const { return startLevel; }
|
||||
double elapsed() const; // Now calculated from start time
|
||||
void updateElapsedTime(); // Update elapsed time from system clock
|
||||
bool isSoftDropping() const { return softDropping; }
|
||||
const std::array<std::optional<AsteroidCell>, COLS*ROWS>& asteroidCells() const { return asteroidGrid; }
|
||||
|
||||
// Block statistics
|
||||
const std::array<int, PIECE_COUNT>& getBlockCounts() const { return blockCounts; }
|
||||
@ -87,6 +110,9 @@ public:
|
||||
int comboCount() const { return _comboCount; }
|
||||
|
||||
private:
|
||||
static constexpr int ASTEROID_BASE = 100; // sentinel offset for board encoding
|
||||
static constexpr int ASTEROID_MAX_LEVEL = 100;
|
||||
|
||||
std::array<int, COLS*ROWS> board{}; // 0 empty else color index
|
||||
Piece cur{}, hold{}, nextPiece{}; // current, held & next piece
|
||||
bool canHold{true};
|
||||
@ -132,6 +158,17 @@ private:
|
||||
uint32_t hardDropFxId{0};
|
||||
uint64_t pieceSequence{0};
|
||||
|
||||
// Challenge mode state -------------------------------------------------
|
||||
GameMode mode{GameMode::Endless};
|
||||
int challengeLevelIndex{1};
|
||||
int asteroidsRemainingCount{0};
|
||||
int asteroidsTotalThisLevel{0};
|
||||
bool challengeComplete{false};
|
||||
std::array<std::optional<AsteroidCell>, COLS*ROWS> asteroidGrid{};
|
||||
uint32_t challengeSeedBase{0};
|
||||
std::mt19937 challengeRng{ std::random_device{}() };
|
||||
bool challengeLevelActive{false};
|
||||
|
||||
// Internal helpers ----------------------------------------------------
|
||||
void refillBag();
|
||||
void spawn();
|
||||
@ -140,5 +177,14 @@ private:
|
||||
int checkLines(); // Find completed lines and store them
|
||||
void actualClearLines(); // Actually remove lines from board
|
||||
bool tryMoveDown(); // one-row fall; returns true if moved
|
||||
void clearAsteroidGrid();
|
||||
void setupChallengeLevel(int level, bool preserveStats);
|
||||
void placeAsteroidsForLevel(int level);
|
||||
AsteroidType chooseAsteroidTypeForLevel(int level);
|
||||
AsteroidCell makeAsteroidForType(AsteroidType t) const;
|
||||
void handleAsteroidsOnClearedRows(const std::vector<int>& clearedRows,
|
||||
std::array<int, COLS*ROWS>& outBoard,
|
||||
std::array<std::optional<AsteroidCell>, COLS*ROWS>& outAsteroids);
|
||||
void applyAsteroidGravity();
|
||||
// Gravity tuning helpers (public API declared above)
|
||||
};
|
||||
|
||||
@ -1,287 +0,0 @@
|
||||
# Spacetris — Challenge Mode (Asteroids) Implementation Spec for VS Code AI Agent
|
||||
|
||||
> Goal: Implement/extend **CHALLENGE** gameplay in Spacetris (not a separate mode), based on 100 levels with **asteroid** prefilled blocks that must be destroyed to advance.
|
||||
|
||||
---
|
||||
|
||||
## 1) High-level Requirements
|
||||
|
||||
### Modes
|
||||
- Existing mode remains **ENDLESS**.
|
||||
- Add/extend **CHALLENGE** mode with **100 levels**.
|
||||
|
||||
### Core Challenge Loop
|
||||
- Each level starts with **prefilled obstacle blocks** called **Asteroids**.
|
||||
- **Level N** starts with **N asteroids** (placed increasingly higher as level increases).
|
||||
- Player advances to the next level when **ALL asteroids are destroyed**.
|
||||
- Gravity (and optionally lock pressure) increases per level.
|
||||
|
||||
### Asteroid concept
|
||||
Asteroids are special blocks placed into the grid at level start:
|
||||
- They are **not** player-controlled pieces.
|
||||
- They have **types** and **hit points** (how many times they must be cleared via line clears).
|
||||
|
||||
---
|
||||
|
||||
## 2) Asteroid Types & Rules
|
||||
|
||||
Define asteroid types and their behavior:
|
||||
|
||||
### A) Normal Asteroid
|
||||
- `hitsRemaining = 1`
|
||||
- Removed when its row is cleared once.
|
||||
- Never moves (no gravity).
|
||||
|
||||
### B) Armored Asteroid
|
||||
- `hitsRemaining = 2`
|
||||
- On first line clear that includes it: decrement hits and change to cracked visual state.
|
||||
- On second clear: removed.
|
||||
- Never moves (no gravity).
|
||||
|
||||
### C) Falling Asteroid
|
||||
- `hitsRemaining = 2`
|
||||
- On first clear: decrement hits, then **becomes gravity-enabled** (drops until resting).
|
||||
- On second clear: removed.
|
||||
|
||||
### D) Core Asteroid (late levels)
|
||||
- `hitsRemaining = 3`
|
||||
- On each clear: decrement hits and change visual state.
|
||||
- After first hit (or after any hit — choose consistent rule) it becomes gravity-enabled.
|
||||
- On final clear: removed (optionally trigger bigger VFX).
|
||||
|
||||
**Important:** These are all within the same CHALLENGE mode.
|
||||
|
||||
---
|
||||
|
||||
## 3) Level Progression Rules (100 Levels)
|
||||
|
||||
### Asteroid Count
|
||||
- `asteroidsToPlace = level` (Level 1 -> 1 asteroid, Level 2 -> 2 asteroids, …)
|
||||
- Recommendation for implementation safety:
|
||||
- If `level` becomes too large to place comfortably, still place `level` but distribute across more rows and allow overlaps only if empty.
|
||||
- If needed, implement a soft cap for placement attempts (avoid infinite loops). If cannot place all, place as many as possible and log/telemetry.
|
||||
|
||||
### Placement Height / Region
|
||||
- Early levels: place in bottom 2–4 rows.
|
||||
- Mid levels: bottom 6–10 rows.
|
||||
- Late levels: up to ~half board height.
|
||||
- Use a function to define a `minRow..maxRow` region based on `level`.
|
||||
|
||||
Example guidance:
|
||||
- `maxRow = boardHeight - 1`
|
||||
- `minRow = boardHeight - 1 - clamp(2 + level/3, 2, boardHeight/2)`
|
||||
|
||||
### Type Distribution by Level (suggested)
|
||||
- Levels 1–9: Normal only
|
||||
- Levels 10–19: add Armored (small %)
|
||||
- Levels 20–59: add Falling (increasing %)
|
||||
- Levels 60–100: add Core (increasing %)
|
||||
|
||||
---
|
||||
|
||||
## 4) Difficulty Scaling
|
||||
|
||||
### Gravity Speed Scaling
|
||||
Implement per-level gravity scale:
|
||||
- `gravity = baseGravity * (1.0f + level * 0.02f)` (tune)
|
||||
- Or use a curve/table.
|
||||
|
||||
Optional additional scaling:
|
||||
- Reduced lock delay slightly at higher levels
|
||||
- Slightly faster DAS/ARR (if implemented)
|
||||
|
||||
---
|
||||
|
||||
## 5) Win/Lose Conditions
|
||||
|
||||
### Level Completion
|
||||
- Level completes when: `asteroidsRemaining == 0`
|
||||
- Then:
|
||||
- Clear board (or keep board — choose one consistent behavior; recommended: **clear board** for clean progression).
|
||||
- Show short transition (optional).
|
||||
- Load next level, until level 100.
|
||||
- After level 100 completion: show completion screen + stats.
|
||||
|
||||
### Game Over
|
||||
- Standard Tetris game over: stack reaches spawn/top (existing behavior).
|
||||
|
||||
---
|
||||
|
||||
## 6) Rendering / UI Requirements
|
||||
|
||||
### Visual Differentiation
|
||||
Asteroids must be visually distinct from normal tetromino blocks.
|
||||
|
||||
Provide visual states:
|
||||
- Normal: rock texture
|
||||
- Armored: plated / darker
|
||||
- Cracked: visible cracks
|
||||
- Falling: glow rim / hazard stripes
|
||||
- Core: pulsing inner core
|
||||
|
||||
Minimum UI additions (Challenge):
|
||||
- Display `LEVEL: X/100`
|
||||
- Display `ASTEROIDS REMAINING: N` (or an icon counter)
|
||||
|
||||
---
|
||||
|
||||
## 7) Data Structures (C++ Guidance)
|
||||
|
||||
### Cell Representation
|
||||
Each grid cell must store:
|
||||
- Whether occupied
|
||||
- If occupied: is it part of normal tetromino or an asteroid
|
||||
- If asteroid: type + hitsRemaining + gravityEnabled + visualState
|
||||
|
||||
Suggested enums:
|
||||
```cpp
|
||||
enum class CellKind { Empty, Tetromino, Asteroid };
|
||||
|
||||
enum class AsteroidType { Normal, Armored, Falling, Core };
|
||||
|
||||
struct AsteroidCell {
|
||||
AsteroidType type;
|
||||
uint8_t hitsRemaining;
|
||||
bool gravityEnabled;
|
||||
uint8_t visualState; // optional (e.g. 0..n)
|
||||
};
|
||||
|
||||
struct Cell {
|
||||
CellKind kind;
|
||||
// For Tetromino: color/type id
|
||||
// For Asteroid: AsteroidCell data
|
||||
};
|
||||
````
|
||||
|
||||
---
|
||||
|
||||
## 8) Line Clear Processing Rules (Important)
|
||||
|
||||
When a line is cleared:
|
||||
|
||||
1. Detect full rows (existing).
|
||||
2. For each cleared row:
|
||||
|
||||
* For each cell:
|
||||
|
||||
* If `kind == Asteroid`:
|
||||
|
||||
* `hitsRemaining--`
|
||||
* If `hitsRemaining == 0`: remove (cell becomes Empty)
|
||||
* Else:
|
||||
|
||||
* Update its visual state (cracked/damaged)
|
||||
* If asteroid type is Falling/Core and rule says it becomes gravity-enabled on first hit:
|
||||
|
||||
* `gravityEnabled = true`
|
||||
3. After clearing rows and collapsing the grid:
|
||||
|
||||
* Apply **asteroid gravity step**:
|
||||
|
||||
* For all gravity-enabled asteroid cells: let them fall until resting.
|
||||
* Ensure stable iteration (bottom-up scan).
|
||||
4. Recount asteroids remaining; if 0 -> level complete.
|
||||
|
||||
**Note:** Decide whether gravity-enabled asteroids fall immediately after the first hit (recommended) and whether they fall as individual cells (recommended) or as clusters (optional later).
|
||||
|
||||
---
|
||||
|
||||
## 9) Asteroid Gravity Algorithm (Simple + Stable)
|
||||
|
||||
Implement a pass:
|
||||
|
||||
* Iterate from bottom-2 to top (bottom-up).
|
||||
* If cell is gravity-enabled asteroid and below is empty:
|
||||
|
||||
* Move down by one
|
||||
* Repeat passes until no movement OR do a while-loop per cell to drop fully.
|
||||
|
||||
Be careful to avoid skipping cells when moving:
|
||||
|
||||
* Use bottom-up iteration and drop-to-bottom logic.
|
||||
|
||||
---
|
||||
|
||||
## 10) Level Generation (Deterministic Option)
|
||||
|
||||
To make challenge reproducible:
|
||||
|
||||
* Use a seed: `seed = baseSeed + level`
|
||||
* Place asteroids with RNG based on level seed.
|
||||
|
||||
Placement constraints:
|
||||
|
||||
* Avoid placing asteroids in the spawn zone/top rows.
|
||||
* Avoid creating impossible scenarios too early:
|
||||
|
||||
* For early levels, ensure at least one vertical shaft exists.
|
||||
|
||||
---
|
||||
|
||||
## 11) Tasks Checklist for AI Agent
|
||||
|
||||
### A) Add Challenge Level System
|
||||
|
||||
* [ ] Add `currentLevel (1..100)` and `mode == CHALLENGE`.
|
||||
* [ ] Add `StartChallengeLevel(level)` function.
|
||||
* [ ] Reset/prepare board state for each level (recommended: clear board).
|
||||
|
||||
### B) Asteroid Placement
|
||||
|
||||
* [ ] Implement `PlaceAsteroids(level)`:
|
||||
|
||||
* Determine region of rows
|
||||
* Choose type distribution
|
||||
* Place `level` asteroid cells into empty spots
|
||||
|
||||
### C) Line Clear Hook
|
||||
|
||||
* [ ] Modify existing line clear code:
|
||||
|
||||
* Apply asteroid hit logic
|
||||
* Update visuals
|
||||
* Enable gravity where required
|
||||
|
||||
### D) Gravity-enabled Asteroids
|
||||
|
||||
* [ ] Implement `ApplyAsteroidGravity()` after line clears and board collapse.
|
||||
|
||||
### E) Level Completion
|
||||
|
||||
* [ ] Track `asteroidsRemaining`.
|
||||
* [ ] When 0: trigger level transition and `StartChallengeLevel(level+1)`.
|
||||
|
||||
### F) UI
|
||||
|
||||
* [ ] Add level & asteroids remaining display.
|
||||
|
||||
---
|
||||
|
||||
## 12) Acceptance Criteria
|
||||
|
||||
* Level 1 spawns exactly 1 asteroid.
|
||||
* Level N spawns N asteroids.
|
||||
* Destroying asteroids requires:
|
||||
|
||||
* Normal: 1 clear
|
||||
* Armored: 2 clears
|
||||
* Falling: 2 clears + becomes gravity-enabled after first hit
|
||||
* Core: 3 clears (+ gravity-enabled rule)
|
||||
* Player advances only when all asteroids are destroyed.
|
||||
* Gravity increases by level and is clearly noticeable by mid-levels.
|
||||
* No infinite loops in placement or gravity.
|
||||
* Challenge works end-to-end through level 100.
|
||||
|
||||
---
|
||||
|
||||
## 13) Notes / Tuning Hooks
|
||||
|
||||
Expose tuning constants:
|
||||
|
||||
* `baseGravity`
|
||||
* `gravityPerLevel`
|
||||
* `minAsteroidRow(level)`
|
||||
* `typeDistribution(level)` weights
|
||||
* `coreGravityOnHit` rule
|
||||
|
||||
---
|
||||
@ -867,6 +867,8 @@ void GameRenderer::renderPlayingState(
|
||||
|
||||
// Draw the game board
|
||||
const auto &board = game->boardRef();
|
||||
const auto &asteroidCells = game->asteroidCells();
|
||||
const bool challengeMode = game->getMode() == GameMode::Challenge;
|
||||
float impactStrength = 0.0f;
|
||||
float impactEased = 0.0f;
|
||||
std::array<uint8_t, Game::COLS * Game::ROWS> impactMask{};
|
||||
@ -954,7 +956,39 @@ void GameRenderer::renderPlayingState(
|
||||
bx += amplitude * std::sin(t * freq);
|
||||
by += amplitude * 0.75f * std::cos(t * (freq + 1.1f));
|
||||
}
|
||||
drawBlockTexture(renderer, blocksTex, bx, by, finalBlockSize, v - 1);
|
||||
|
||||
bool isAsteroid = challengeMode && asteroidCells[cellIdx].has_value();
|
||||
if (isAsteroid) {
|
||||
const AsteroidCell& cell = *asteroidCells[cellIdx];
|
||||
SDL_Color base{};
|
||||
switch (cell.type) {
|
||||
case AsteroidType::Normal: base = SDL_Color{172, 138, 104, 255}; break;
|
||||
case AsteroidType::Armored: base = SDL_Color{130, 150, 176, 255}; break;
|
||||
case AsteroidType::Falling: base = SDL_Color{210, 120, 82, 255}; break;
|
||||
case AsteroidType::Core: base = SDL_Color{198, 78, 200, 255}; break;
|
||||
}
|
||||
float hpScale = std::clamp(static_cast<float>(cell.hitsRemaining) / 3.0f, 0.25f, 1.0f);
|
||||
SDL_Color fill{
|
||||
static_cast<Uint8>(base.r * hpScale + 40 * (1.0f - hpScale)),
|
||||
static_cast<Uint8>(base.g * hpScale + 40 * (1.0f - hpScale)),
|
||||
static_cast<Uint8>(base.b * hpScale + 40 * (1.0f - hpScale)),
|
||||
255
|
||||
};
|
||||
drawRect(renderer, bx, by, finalBlockSize - 1, finalBlockSize - 1, fill);
|
||||
// Subtle outline to differentiate types
|
||||
SDL_Color outline = base;
|
||||
outline.a = 220;
|
||||
SDL_FRect border{bx + 1.0f, by + 1.0f, finalBlockSize - 2.0f, finalBlockSize - 2.0f};
|
||||
SDL_SetRenderDrawColor(renderer, outline.r, outline.g, outline.b, outline.a);
|
||||
SDL_RenderRect(renderer, &border);
|
||||
if (cell.gravityEnabled) {
|
||||
SDL_SetRenderDrawColor(renderer, 255, 230, 120, 180);
|
||||
SDL_FRect glow{bx + 2.0f, by + 2.0f, finalBlockSize - 4.0f, finalBlockSize - 4.0f};
|
||||
SDL_RenderRect(renderer, &glow);
|
||||
}
|
||||
} else {
|
||||
drawBlockTexture(renderer, blocksTex, bx, by, finalBlockSize, v - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1287,8 +1321,12 @@ void GameRenderer::renderPlayingState(
|
||||
|
||||
char levelStr[16];
|
||||
snprintf(levelStr, sizeof(levelStr), "%02d", game->level());
|
||||
char challengeLevelStr[16];
|
||||
snprintf(challengeLevelStr, sizeof(challengeLevelStr), "%02d/100", game->challengeLevel());
|
||||
char asteroidStr[32];
|
||||
snprintf(asteroidStr, sizeof(asteroidStr), "%d LEFT", game->asteroidsRemaining());
|
||||
|
||||
// Next level progress
|
||||
// Next level progress (endless only)
|
||||
int startLv = game->startLevelBase();
|
||||
int firstThreshold = (startLv + 1) * 10;
|
||||
int linesDone = game->lines();
|
||||
@ -1343,12 +1381,22 @@ void GameRenderer::renderPlayingState(
|
||||
statLines.push_back({scoreStr, 25.0f, 0.9f, valueColor});
|
||||
statLines.push_back({"LINES", 70.0f, 1.0f, labelColor});
|
||||
statLines.push_back({linesStr, 95.0f, 0.9f, valueColor});
|
||||
statLines.push_back({"LEVEL", 140.0f, 1.0f, labelColor});
|
||||
statLines.push_back({levelStr, 165.0f, 0.9f, valueColor});
|
||||
statLines.push_back({"NEXT LVL", 200.0f, 1.0f, labelColor});
|
||||
statLines.push_back({nextStr, 225.0f, 0.9f, nextColor});
|
||||
statLines.push_back({"TIME", 265.0f, 1.0f, labelColor});
|
||||
statLines.push_back({timeStr, 290.0f, 0.9f, valueColor});
|
||||
|
||||
if (game->getMode() == GameMode::Challenge) {
|
||||
statLines.push_back({"LEVEL", 140.0f, 1.0f, labelColor});
|
||||
statLines.push_back({challengeLevelStr, 165.0f, 0.9f, valueColor});
|
||||
statLines.push_back({"ASTEROIDS", 200.0f, 1.0f, labelColor});
|
||||
statLines.push_back({asteroidStr, 225.0f, 0.9f, nextColor});
|
||||
statLines.push_back({"TIME", 265.0f, 1.0f, labelColor});
|
||||
statLines.push_back({timeStr, 290.0f, 0.9f, valueColor});
|
||||
} else {
|
||||
statLines.push_back({"LEVEL", 140.0f, 1.0f, labelColor});
|
||||
statLines.push_back({levelStr, 165.0f, 0.9f, valueColor});
|
||||
statLines.push_back({"NEXT LVL", 200.0f, 1.0f, labelColor});
|
||||
statLines.push_back({nextStr, 225.0f, 0.9f, nextColor});
|
||||
statLines.push_back({"TIME", 265.0f, 1.0f, labelColor});
|
||||
statLines.push_back({timeStr, 290.0f, 0.9f, valueColor});
|
||||
}
|
||||
|
||||
if (debugEnabled) {
|
||||
SDL_Color debugLabelColor{150, 150, 150, 255};
|
||||
|
||||
@ -442,7 +442,7 @@ void MenuState::handleEvent(const SDL_Event& e) {
|
||||
case SDL_SCANCODE_LEFT:
|
||||
case SDL_SCANCODE_UP:
|
||||
{
|
||||
const int total = 6;
|
||||
const int total = 7;
|
||||
selectedButton = (selectedButton + total - 1) % total;
|
||||
// brief bright flash on navigation
|
||||
buttonFlash = 1.0;
|
||||
@ -451,7 +451,7 @@ void MenuState::handleEvent(const SDL_Event& e) {
|
||||
case SDL_SCANCODE_RIGHT:
|
||||
case SDL_SCANCODE_DOWN:
|
||||
{
|
||||
const int total = 6;
|
||||
const int total = 7;
|
||||
selectedButton = (selectedButton + 1) % total;
|
||||
// brief bright flash on navigation
|
||||
buttonFlash = 1.0;
|
||||
@ -465,9 +465,19 @@ void MenuState::handleEvent(const SDL_Event& e) {
|
||||
}
|
||||
switch (selectedButton) {
|
||||
case 0:
|
||||
// Endless play
|
||||
if (ctx.game) ctx.game->setMode(GameMode::Endless);
|
||||
triggerPlay();
|
||||
break;
|
||||
case 1:
|
||||
// Start challenge run at level 1
|
||||
if (ctx.game) {
|
||||
ctx.game->setMode(GameMode::Challenge);
|
||||
ctx.game->startChallengeRun(1);
|
||||
}
|
||||
triggerPlay();
|
||||
break;
|
||||
case 2:
|
||||
// Toggle inline level selector HUD (show/hide)
|
||||
if (!levelPanelVisible && !levelPanelAnimating) {
|
||||
levelPanelAnimating = true;
|
||||
@ -479,7 +489,7 @@ void MenuState::handleEvent(const SDL_Event& e) {
|
||||
levelDirection = -1; // hide
|
||||
}
|
||||
break;
|
||||
case 2:
|
||||
case 3:
|
||||
// Toggle the options panel with an animated slide-in/out.
|
||||
if (!optionsVisible && !optionsAnimating) {
|
||||
optionsAnimating = true;
|
||||
@ -489,7 +499,7 @@ void MenuState::handleEvent(const SDL_Event& e) {
|
||||
optionsDirection = -1; // hide
|
||||
}
|
||||
break;
|
||||
case 3:
|
||||
case 4:
|
||||
// Toggle the inline HELP HUD (show/hide)
|
||||
if (!helpPanelVisible && !helpPanelAnimating) {
|
||||
helpPanelAnimating = true;
|
||||
@ -500,7 +510,7 @@ void MenuState::handleEvent(const SDL_Event& e) {
|
||||
helpDirection = -1; // hide
|
||||
}
|
||||
break;
|
||||
case 4:
|
||||
case 5:
|
||||
// Toggle the inline ABOUT HUD (show/hide)
|
||||
if (!aboutPanelVisible && !aboutPanelAnimating) {
|
||||
aboutPanelAnimating = true;
|
||||
@ -510,7 +520,7 @@ void MenuState::handleEvent(const SDL_Event& e) {
|
||||
aboutDirection = -1;
|
||||
}
|
||||
break;
|
||||
case 5:
|
||||
case 6:
|
||||
// Show the inline exit HUD
|
||||
if (!exitPanelVisible && !exitPanelAnimating) {
|
||||
exitPanelAnimating = true;
|
||||
|
||||
@ -18,12 +18,18 @@ PlayingState::PlayingState(StateContext& ctx) : State(ctx) {}
|
||||
|
||||
void PlayingState::onEnter() {
|
||||
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[PLAYING] Entering Playing state");
|
||||
// Initialize the game with the selected starting level
|
||||
if (ctx.game && ctx.startLevelSelection) {
|
||||
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[PLAYING] Resetting game with level %d", *ctx.startLevelSelection);
|
||||
ctx.game->reset(*ctx.startLevelSelection);
|
||||
}
|
||||
// Initialize the game based on mode: endless uses chosen start level, challenge keeps its run state
|
||||
if (ctx.game) {
|
||||
if (ctx.game->getMode() == GameMode::Endless) {
|
||||
if (ctx.startLevelSelection) {
|
||||
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[PLAYING] Resetting game with level %d", *ctx.startLevelSelection);
|
||||
ctx.game->reset(*ctx.startLevelSelection);
|
||||
}
|
||||
} else {
|
||||
// Challenge run is prepared before entering; ensure gameplay is unpaused
|
||||
ctx.game->setPaused(false);
|
||||
}
|
||||
|
||||
s_lastPieceSequence = ctx.game->getCurrentPieceSequence();
|
||||
s_pendingTransport = false;
|
||||
}
|
||||
|
||||
@ -22,11 +22,12 @@ BottomMenu buildBottomMenu(const MenuLayoutParams& params, int startLevel) {
|
||||
std::snprintf(levelBtnText, sizeof(levelBtnText), "LEVEL %d", startLevel);
|
||||
|
||||
menu.buttons[0] = Button{ BottomMenuItem::Play, rects[0], "PLAY", false };
|
||||
menu.buttons[1] = Button{ BottomMenuItem::Level, rects[1], levelBtnText, true };
|
||||
menu.buttons[2] = Button{ BottomMenuItem::Options, rects[2], "OPTIONS", true };
|
||||
menu.buttons[3] = Button{ BottomMenuItem::Help, rects[3], "HELP", true };
|
||||
menu.buttons[4] = Button{ BottomMenuItem::About, rects[4], "ABOUT", true };
|
||||
menu.buttons[5] = Button{ BottomMenuItem::Exit, rects[5], "EXIT", true };
|
||||
menu.buttons[1] = Button{ BottomMenuItem::Challenge, rects[1], "CHALLENGE", false };
|
||||
menu.buttons[2] = Button{ BottomMenuItem::Level, rects[2], levelBtnText, true };
|
||||
menu.buttons[3] = Button{ BottomMenuItem::Options, rects[3], "OPTIONS", true };
|
||||
menu.buttons[4] = Button{ BottomMenuItem::Help, rects[4], "HELP", true };
|
||||
menu.buttons[5] = Button{ BottomMenuItem::About, rects[5], "ABOUT", true };
|
||||
menu.buttons[6] = Button{ BottomMenuItem::Exit, rects[6], "EXIT", true };
|
||||
|
||||
return menu;
|
||||
}
|
||||
@ -60,8 +61,15 @@ void renderBottomMenu(SDL_Renderer* renderer,
|
||||
const double aMul = std::clamp(baseMul + (playIsActive ? flashMul : 0.0), 0.0, 1.0);
|
||||
|
||||
if (!b.textOnly) {
|
||||
const bool isPlay = (i == 0);
|
||||
const bool isChallenge = (i == 1);
|
||||
SDL_Color bgCol{ 18, 22, 28, static_cast<Uint8>(std::round(180.0 * aMul)) };
|
||||
SDL_Color bdCol{ 255, 200, 70, static_cast<Uint8>(std::round(220.0 * aMul)) };
|
||||
if (isChallenge) {
|
||||
// Give Challenge a teal accent to distinguish from Play
|
||||
bgCol = SDL_Color{ 18, 36, 36, static_cast<Uint8>(std::round(190.0 * aMul)) };
|
||||
bdCol = SDL_Color{ 120, 255, 220, static_cast<Uint8>(std::round(230.0 * aMul)) };
|
||||
}
|
||||
UIRenderer::drawButton(renderer, font, cx, cy, r.w, r.h,
|
||||
b.label, isHovered, isSelected,
|
||||
bgCol, bdCol, false, nullptr);
|
||||
@ -74,14 +82,14 @@ void renderBottomMenu(SDL_Renderer* renderer,
|
||||
}
|
||||
}
|
||||
|
||||
// '+' separators between the bottom HUD buttons (indices 1..last)
|
||||
// '+' separators between the bottom HUD buttons (indices 2..last)
|
||||
{
|
||||
SDL_BlendMode prevBlend = SDL_BLENDMODE_NONE;
|
||||
SDL_GetRenderDrawBlendMode(renderer, &prevBlend);
|
||||
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);
|
||||
SDL_SetRenderDrawColor(renderer, 120, 220, 255, static_cast<Uint8>(std::round(180.0 * baseMul)));
|
||||
|
||||
const int firstSmall = 1;
|
||||
const int firstSmall = 2;
|
||||
const int lastSmall = MENU_BTN_COUNT - 1;
|
||||
float y = menu.buttons[firstSmall].rect.y + menu.buttons[firstSmall].rect.h * 0.5f;
|
||||
for (int i = firstSmall; i < lastSmall; ++i) {
|
||||
|
||||
@ -15,11 +15,12 @@ namespace ui {
|
||||
|
||||
enum class BottomMenuItem : int {
|
||||
Play = 0,
|
||||
Level = 1,
|
||||
Options = 2,
|
||||
Help = 3,
|
||||
About = 4,
|
||||
Exit = 5,
|
||||
Challenge = 1,
|
||||
Level = 2,
|
||||
Options = 3,
|
||||
Help = 4,
|
||||
About = 5,
|
||||
Exit = 6,
|
||||
};
|
||||
|
||||
struct Button {
|
||||
|
||||
@ -12,28 +12,32 @@ std::array<SDL_FRect, MENU_BTN_COUNT> computeMenuButtonRects(const MenuLayoutPar
|
||||
float contentOffsetY = (p.winH - LOGICAL_H * p.logicalScale) * 0.5f / p.logicalScale;
|
||||
|
||||
// Cockpit HUD layout (matches main_screen art):
|
||||
// - A big centered PLAY button
|
||||
// - A second row of 5 smaller buttons: LEVEL / OPTIONS / HELP / ABOUT / EXIT
|
||||
// - Top row: PLAY and CHALLENGE (big buttons)
|
||||
// - Second row: LEVEL / OPTIONS / HELP / ABOUT / EXIT (smaller buttons)
|
||||
const float marginX = std::max(24.0f, LOGICAL_W * 0.03f);
|
||||
const float marginBottom = std::max(26.0f, LOGICAL_H * 0.03f);
|
||||
const float availableW = std::max(120.0f, LOGICAL_W - marginX * 2.0f);
|
||||
|
||||
float playW = std::min(230.0f, availableW * 0.27f);
|
||||
float playH = 35.0f;
|
||||
float smallW = std::min(220.0f, availableW * 0.23f);
|
||||
float playW = std::min(220.0f, availableW * 0.25f);
|
||||
float playH = 36.0f;
|
||||
float bigGap = 28.0f;
|
||||
float smallW = std::min(210.0f, availableW * 0.22f);
|
||||
float smallH = 34.0f;
|
||||
float smallSpacing = 28.0f;
|
||||
float smallSpacing = 26.0f;
|
||||
|
||||
// Scale down for narrow windows so nothing goes offscreen.
|
||||
const int smallCount = MENU_BTN_COUNT - 1;
|
||||
const int smallCount = MENU_BTN_COUNT - 2;
|
||||
float smallTotal = smallW * static_cast<float>(smallCount) + smallSpacing * static_cast<float>(smallCount - 1);
|
||||
if (smallTotal > availableW) {
|
||||
float s = availableW / smallTotal;
|
||||
float topRowTotal = playW * 2.0f + bigGap;
|
||||
if (smallTotal > availableW || topRowTotal > availableW) {
|
||||
float s = availableW / std::max(std::max(smallTotal, topRowTotal), 1.0f);
|
||||
smallW *= s;
|
||||
smallH *= s;
|
||||
smallSpacing *= s;
|
||||
playW *= s;
|
||||
playH = std::max(26.0f, playH * std::max(0.75f, s));
|
||||
bigGap *= s;
|
||||
playW = std::min(playW, availableW);
|
||||
playH *= std::max(0.75f, s);
|
||||
}
|
||||
|
||||
float centerX = LOGICAL_W * 0.5f + contentOffsetX;
|
||||
@ -44,7 +48,11 @@ std::array<SDL_FRect, MENU_BTN_COUNT> computeMenuButtonRects(const MenuLayoutPar
|
||||
float playCY = smallCY - smallH * 0.5f - rowGap - playH * 0.5f;
|
||||
|
||||
std::array<SDL_FRect, MENU_BTN_COUNT> rects{};
|
||||
rects[0] = SDL_FRect{ centerX - playW * 0.5f, playCY - playH * 0.5f, playW, playH };
|
||||
// Top row big buttons
|
||||
float playLeft = centerX - (playW + bigGap * 0.5f);
|
||||
float challengeLeft = centerX + bigGap * 0.5f;
|
||||
rects[0] = SDL_FRect{ playLeft, playCY - playH * 0.5f, playW, playH };
|
||||
rects[1] = SDL_FRect{ challengeLeft, playCY - playH * 0.5f, playW, playH };
|
||||
|
||||
float rowW = smallW * static_cast<float>(smallCount) + smallSpacing * static_cast<float>(smallCount - 1);
|
||||
float left = centerX - rowW * 0.5f;
|
||||
@ -55,7 +63,7 @@ std::array<SDL_FRect, MENU_BTN_COUNT> computeMenuButtonRects(const MenuLayoutPar
|
||||
|
||||
for (int i = 0; i < smallCount; ++i) {
|
||||
float x = left + i * (smallW + smallSpacing);
|
||||
rects[i + 1] = SDL_FRect{ x, smallCY - smallH * 0.5f, smallW, smallH };
|
||||
rects[i + 2] = SDL_FRect{ x, smallCY - smallH * 0.5f, smallW, smallH };
|
||||
}
|
||||
return rects;
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
static constexpr int MENU_BTN_COUNT = 6;
|
||||
static constexpr int MENU_BTN_COUNT = 7;
|
||||
static constexpr float MENU_SMALL_THRESHOLD = 700.0f;
|
||||
static constexpr float MENU_BTN_WIDTH_LARGE = 300.0f;
|
||||
static constexpr float MENU_BTN_WIDTH_SMALL_FACTOR = 0.4f; // multiplied by LOGICAL_W
|
||||
|
||||
Reference in New Issue
Block a user