Added hold block and minor fixes
BIN
assets/images/blocks90px_002.png
Normal file
|
After Width: | Height: | Size: 87 KiB |
BIN
assets/images/blocks90px_003.png
Normal file
|
After Width: | Height: | Size: 112 KiB |
BIN
assets/images/hold_panel.png
Normal file
|
After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 253 KiB After Width: | Height: | Size: 253 KiB |
@ -77,7 +77,11 @@ bool AssetLoader::performStep() {
|
|||||||
m_errors.push_back(std::string("CreateTexture failed: ") + fullPath);
|
m_errors.push_back(std::string("CreateTexture failed: ") + fullPath);
|
||||||
} else {
|
} else {
|
||||||
std::lock_guard<std::mutex> lk(m_texturesMutex);
|
std::lock_guard<std::mutex> lk(m_texturesMutex);
|
||||||
m_textures[path] = tex;
|
auto& slot = m_textures[path];
|
||||||
|
if (slot && slot != tex) {
|
||||||
|
SDL_DestroyTexture(slot);
|
||||||
|
}
|
||||||
|
slot = tex;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -95,6 +99,19 @@ bool AssetLoader::performStep() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void AssetLoader::adoptTexture(const std::string& path, SDL_Texture* texture) {
|
||||||
|
if (!texture) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::lock_guard<std::mutex> lk(m_texturesMutex);
|
||||||
|
auto& slot = m_textures[path];
|
||||||
|
if (slot && slot != texture) {
|
||||||
|
SDL_DestroyTexture(slot);
|
||||||
|
}
|
||||||
|
slot = texture;
|
||||||
|
}
|
||||||
|
|
||||||
float AssetLoader::getProgress() const {
|
float AssetLoader::getProgress() const {
|
||||||
int total = m_totalTasks.load(std::memory_order_relaxed);
|
int total = m_totalTasks.load(std::memory_order_relaxed);
|
||||||
if (total <= 0) return 1.0f;
|
if (total <= 0) return 1.0f;
|
||||||
|
|||||||
@ -39,6 +39,10 @@ public:
|
|||||||
// Get a loaded texture (or nullptr if not loaded).
|
// Get a loaded texture (or nullptr if not loaded).
|
||||||
SDL_Texture* getTexture(const std::string& path) const;
|
SDL_Texture* getTexture(const std::string& path) const;
|
||||||
|
|
||||||
|
// Adopt an externally-created texture so AssetLoader owns its lifetime.
|
||||||
|
// If a texture is already registered for this path, it will be replaced.
|
||||||
|
void adoptTexture(const std::string& path, SDL_Texture* texture);
|
||||||
|
|
||||||
// Return currently-loading path (empty when idle).
|
// Return currently-loading path (empty when idle).
|
||||||
std::string getCurrentLoading() const;
|
std::string getCurrentLoading() const;
|
||||||
|
|
||||||
|
|||||||
@ -139,6 +139,7 @@ struct TetrisApp::Impl {
|
|||||||
SDL_Texture* scorePanelTex = nullptr;
|
SDL_Texture* scorePanelTex = nullptr;
|
||||||
SDL_Texture* statisticsPanelTex = nullptr;
|
SDL_Texture* statisticsPanelTex = nullptr;
|
||||||
SDL_Texture* nextPanelTex = nullptr;
|
SDL_Texture* nextPanelTex = nullptr;
|
||||||
|
SDL_Texture* holdPanelTex = nullptr;
|
||||||
|
|
||||||
BackgroundManager levelBackgrounds;
|
BackgroundManager levelBackgrounds;
|
||||||
int startLevelSelection = 0;
|
int startLevelSelection = 0;
|
||||||
@ -973,7 +974,8 @@ void TetrisApp::Impl::runLoop()
|
|||||||
"assets/images/blocks90px_001.bmp",
|
"assets/images/blocks90px_001.bmp",
|
||||||
"assets/images/panel_score.png",
|
"assets/images/panel_score.png",
|
||||||
"assets/images/statistics_panel.png",
|
"assets/images/statistics_panel.png",
|
||||||
"assets/images/next_panel.png"
|
"assets/images/next_panel.png",
|
||||||
|
"assets/images/hold_panel.png"
|
||||||
};
|
};
|
||||||
for (auto &p : queuedPaths) {
|
for (auto &p : queuedPaths) {
|
||||||
loadingManager->queueTexture(p);
|
loadingManager->queueTexture(p);
|
||||||
@ -1007,10 +1009,11 @@ void TetrisApp::Impl::runLoop()
|
|||||||
logoTex = assetLoader.getTexture("assets/images/spacetris.png");
|
logoTex = assetLoader.getTexture("assets/images/spacetris.png");
|
||||||
logoSmallTex = assetLoader.getTexture("assets/images/spacetris.png");
|
logoSmallTex = assetLoader.getTexture("assets/images/spacetris.png");
|
||||||
mainScreenTex = assetLoader.getTexture("assets/images/main_screen.png");
|
mainScreenTex = assetLoader.getTexture("assets/images/main_screen.png");
|
||||||
blocksTex = assetLoader.getTexture("assets/images/blocks90px_001.bmp");
|
blocksTex = assetLoader.getTexture("assets/images/blocks90px_001.png");
|
||||||
scorePanelTex = assetLoader.getTexture("assets/images/panel_score.png");
|
scorePanelTex = assetLoader.getTexture("assets/images/panel_score.png");
|
||||||
statisticsPanelTex = assetLoader.getTexture("assets/images/statistics_panel.png");
|
statisticsPanelTex = assetLoader.getTexture("assets/images/statistics_panel.png");
|
||||||
nextPanelTex = assetLoader.getTexture("assets/images/next_panel.png");
|
nextPanelTex = assetLoader.getTexture("assets/images/next_panel.png");
|
||||||
|
holdPanelTex = assetLoader.getTexture("assets/images/hold_panel.png");
|
||||||
|
|
||||||
auto ensureTextureSize = [&](SDL_Texture* tex, int& outW, int& outH) {
|
auto ensureTextureSize = [&](SDL_Texture* tex, int& outW, int& outH) {
|
||||||
if (!tex) return;
|
if (!tex) return;
|
||||||
@ -1027,17 +1030,22 @@ void TetrisApp::Impl::runLoop()
|
|||||||
|
|
||||||
auto legacyLoad = [&](const std::string& p, SDL_Texture*& outTex, int* outW = nullptr, int* outH = nullptr) {
|
auto legacyLoad = [&](const std::string& p, SDL_Texture*& outTex, int* outW = nullptr, int* outH = nullptr) {
|
||||||
if (!outTex) {
|
if (!outTex) {
|
||||||
outTex = textureLoader->loadFromImage(renderer, p, outW, outH);
|
SDL_Texture* loaded = textureLoader->loadFromImage(renderer, p, outW, outH);
|
||||||
|
if (loaded) {
|
||||||
|
outTex = loaded;
|
||||||
|
assetLoader.adoptTexture(p, loaded);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
legacyLoad("assets/images/spacetris.png", logoTex);
|
legacyLoad("assets/images/spacetris.png", logoTex);
|
||||||
legacyLoad("assets/images/spacetris.png", logoSmallTex, &logoSmallW, &logoSmallH);
|
legacyLoad("assets/images/spacetris.png", logoSmallTex, &logoSmallW, &logoSmallH);
|
||||||
legacyLoad("assets/images/main_screen.png", mainScreenTex, &mainScreenW, &mainScreenH);
|
legacyLoad("assets/images/main_screen.png", mainScreenTex, &mainScreenW, &mainScreenH);
|
||||||
legacyLoad("assets/images/blocks90px_001.bmp", blocksTex);
|
legacyLoad("assets/images/blocks90px_001.png", blocksTex);
|
||||||
legacyLoad("assets/images/panel_score.png", scorePanelTex);
|
legacyLoad("assets/images/panel_score.png", scorePanelTex);
|
||||||
legacyLoad("assets/images/statistics_panel.png", statisticsPanelTex);
|
legacyLoad("assets/images/statistics_panel.png", statisticsPanelTex);
|
||||||
legacyLoad("assets/images/next_panel.png", nextPanelTex);
|
legacyLoad("assets/images/next_panel.png", nextPanelTex);
|
||||||
|
legacyLoad("assets/images/hold_panel.png", holdPanelTex);
|
||||||
|
|
||||||
if (!blocksTex) {
|
if (!blocksTex) {
|
||||||
blocksTex = SDL_CreateTexture(renderer, SDL_PIXELFORMAT_RGBA8888, SDL_TEXTUREACCESS_TARGET, 630, 90);
|
blocksTex = SDL_CreateTexture(renderer, SDL_PIXELFORMAT_RGBA8888, SDL_TEXTUREACCESS_TARGET, 630, 90);
|
||||||
@ -1051,6 +1059,9 @@ void TetrisApp::Impl::runLoop()
|
|||||||
SDL_RenderFillRect(renderer, &rect);
|
SDL_RenderFillRect(renderer, &rect);
|
||||||
}
|
}
|
||||||
SDL_SetRenderTarget(renderer, nullptr);
|
SDL_SetRenderTarget(renderer, nullptr);
|
||||||
|
|
||||||
|
// Ensure the generated fallback texture is cleaned up with other assets.
|
||||||
|
assetLoader.adoptTexture("assets/images/blocks90px_001.png", blocksTex);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (musicLoaded) {
|
if (musicLoaded) {
|
||||||
@ -1192,6 +1203,7 @@ void TetrisApp::Impl::runLoop()
|
|||||||
ctx.scorePanelTex = scorePanelTex;
|
ctx.scorePanelTex = scorePanelTex;
|
||||||
ctx.statisticsPanelTex = statisticsPanelTex;
|
ctx.statisticsPanelTex = statisticsPanelTex;
|
||||||
ctx.nextPanelTex = nextPanelTex;
|
ctx.nextPanelTex = nextPanelTex;
|
||||||
|
ctx.holdPanelTex = holdPanelTex;
|
||||||
ctx.mainScreenTex = mainScreenTex;
|
ctx.mainScreenTex = mainScreenTex;
|
||||||
ctx.mainScreenW = mainScreenW;
|
ctx.mainScreenW = mainScreenW;
|
||||||
ctx.mainScreenH = mainScreenH;
|
ctx.mainScreenH = mainScreenH;
|
||||||
@ -1417,7 +1429,14 @@ void TetrisApp::Impl::runLoop()
|
|||||||
break;
|
break;
|
||||||
case AppState::Menu:
|
case AppState::Menu:
|
||||||
if (!mainScreenTex) {
|
if (!mainScreenTex) {
|
||||||
mainScreenTex = textureLoader->loadFromImage(renderer, "assets/images/main_screen.png", &mainScreenW, &mainScreenH);
|
mainScreenTex = assetLoader.getTexture("assets/images/main_screen.png");
|
||||||
|
}
|
||||||
|
if (!mainScreenTex) {
|
||||||
|
SDL_Texture* loaded = textureLoader->loadFromImage(renderer, "assets/images/main_screen.png", &mainScreenW, &mainScreenH);
|
||||||
|
if (loaded) {
|
||||||
|
assetLoader.adoptTexture("assets/images/main_screen.png", loaded);
|
||||||
|
mainScreenTex = loaded;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (menuState) {
|
if (menuState) {
|
||||||
menuState->drawMainButtonNormally = false;
|
menuState->drawMainButtonNormally = false;
|
||||||
@ -1490,6 +1509,7 @@ void TetrisApp::Impl::runLoop()
|
|||||||
ctx.statisticsPanelTex,
|
ctx.statisticsPanelTex,
|
||||||
scorePanelTex,
|
scorePanelTex,
|
||||||
nextPanelTex,
|
nextPanelTex,
|
||||||
|
holdPanelTex,
|
||||||
(float)LOGICAL_W,
|
(float)LOGICAL_W,
|
||||||
(float)LOGICAL_H,
|
(float)LOGICAL_H,
|
||||||
logicalScale,
|
logicalScale,
|
||||||
@ -1669,27 +1689,18 @@ void TetrisApp::Impl::shutdown()
|
|||||||
{
|
{
|
||||||
Settings::instance().save();
|
Settings::instance().save();
|
||||||
|
|
||||||
if (logoTex) {
|
// BackgroundManager owns its own textures.
|
||||||
SDL_DestroyTexture(logoTex);
|
|
||||||
logoTex = nullptr;
|
|
||||||
}
|
|
||||||
if (mainScreenTex) {
|
|
||||||
SDL_DestroyTexture(mainScreenTex);
|
|
||||||
mainScreenTex = nullptr;
|
|
||||||
}
|
|
||||||
levelBackgrounds.reset();
|
levelBackgrounds.reset();
|
||||||
if (blocksTex) {
|
|
||||||
SDL_DestroyTexture(blocksTex);
|
// All textures are owned by AssetLoader (including legacy fallbacks adopted above).
|
||||||
blocksTex = nullptr;
|
logoTex = nullptr;
|
||||||
}
|
logoSmallTex = nullptr;
|
||||||
if (scorePanelTex) {
|
backgroundTex = nullptr;
|
||||||
SDL_DestroyTexture(scorePanelTex);
|
mainScreenTex = nullptr;
|
||||||
scorePanelTex = nullptr;
|
blocksTex = nullptr;
|
||||||
}
|
scorePanelTex = nullptr;
|
||||||
if (logoSmallTex) {
|
statisticsPanelTex = nullptr;
|
||||||
SDL_DestroyTexture(logoSmallTex);
|
nextPanelTex = nullptr;
|
||||||
logoSmallTex = nullptr;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (scoreLoader.joinable()) {
|
if (scoreLoader.joinable()) {
|
||||||
scoreLoader.join();
|
scoreLoader.join();
|
||||||
@ -1704,6 +1715,11 @@ void TetrisApp::Impl::shutdown()
|
|||||||
lineEffect.shutdown();
|
lineEffect.shutdown();
|
||||||
Audio::instance().shutdown();
|
Audio::instance().shutdown();
|
||||||
SoundEffectManager::instance().shutdown();
|
SoundEffectManager::instance().shutdown();
|
||||||
|
|
||||||
|
// Destroy textures before tearing down the renderer/window.
|
||||||
|
assetLoader.shutdown();
|
||||||
|
|
||||||
|
pixelFont.shutdown();
|
||||||
font.shutdown();
|
font.shutdown();
|
||||||
|
|
||||||
TTF_Quit();
|
TTF_Quit();
|
||||||
|
|||||||
@ -130,7 +130,7 @@ namespace Config {
|
|||||||
constexpr const char* LOGO_BMP = "assets/images/logo.bmp";
|
constexpr const char* LOGO_BMP = "assets/images/logo.bmp";
|
||||||
constexpr const char* LOGO_SMALL_BMP = "assets/images/logo_small.bmp";
|
constexpr const char* LOGO_SMALL_BMP = "assets/images/logo_small.bmp";
|
||||||
constexpr const char* BACKGROUND_BMP = "assets/images/main_background.bmp";
|
constexpr const char* BACKGROUND_BMP = "assets/images/main_background.bmp";
|
||||||
constexpr const char* BLOCKS_BMP = "assets/images/blocks90px_001.bmp";
|
constexpr const char* BLOCKS_BMP = "assets/images/2.png";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Audio settings
|
// Audio settings
|
||||||
|
|||||||
@ -1165,6 +1165,7 @@ void ApplicationManager::setupStateHandlers() {
|
|||||||
m_stateContext.statisticsPanelTex,
|
m_stateContext.statisticsPanelTex,
|
||||||
m_stateContext.scorePanelTex,
|
m_stateContext.scorePanelTex,
|
||||||
m_stateContext.nextPanelTex,
|
m_stateContext.nextPanelTex,
|
||||||
|
m_stateContext.holdPanelTex,
|
||||||
LOGICAL_W,
|
LOGICAL_W,
|
||||||
LOGICAL_H,
|
LOGICAL_H,
|
||||||
logicalScale,
|
logicalScale,
|
||||||
|
|||||||
@ -125,6 +125,7 @@ void GameRenderer::renderPlayingState(
|
|||||||
SDL_Texture* statisticsPanelTex,
|
SDL_Texture* statisticsPanelTex,
|
||||||
SDL_Texture* scorePanelTex,
|
SDL_Texture* scorePanelTex,
|
||||||
SDL_Texture* nextPanelTex,
|
SDL_Texture* nextPanelTex,
|
||||||
|
SDL_Texture* holdPanelTex,
|
||||||
float logicalW,
|
float logicalW,
|
||||||
float logicalH,
|
float logicalH,
|
||||||
float logicalScale,
|
float logicalScale,
|
||||||
@ -466,10 +467,76 @@ void GameRenderer::renderPlayingState(
|
|||||||
snprintf(gms, sizeof(gms), "GRAV: %.0f ms (%.2f fps)", gms_val, gfps);
|
snprintf(gms, sizeof(gms), "GRAV: %.0f ms (%.2f fps)", gms_val, gfps);
|
||||||
pixelFont->draw(renderer, logicalW - 260, 10, gms, 0.9f, {200, 200, 220, 255});
|
pixelFont->draw(renderer, logicalW - 260, 10, gms, 0.9f, {200, 200, 220, 255});
|
||||||
|
|
||||||
// Hold piece (if implemented)
|
// Hold panel (always visible): draw background & label; preview shown only when a piece is held.
|
||||||
if (game->held().type < PIECE_COUNT) {
|
{
|
||||||
pixelFont->draw(renderer, statsX + 10, statsY + statsH - 80, "HOLD", 1.0f, {255, 220, 0, 255});
|
float holdBlockH = (finalBlockSize * 0.6f) * 4.0f;
|
||||||
drawSmallPiece(renderer, blocksTex, static_cast<PieceType>(game->held().type), statsX + 60, statsY + statsH - 80, finalBlockSize * 0.6f);
|
// Base panel height; enforce minimum but allow larger to fit texture
|
||||||
|
float panelH = std::max(holdBlockH + 12.0f, 420.0f);
|
||||||
|
// Increase height by ~20% of the hold block to give more vertical room
|
||||||
|
float extraH = holdBlockH * 0.50f;
|
||||||
|
panelH += extraH;
|
||||||
|
const float holdGap = 18.0f;
|
||||||
|
|
||||||
|
// Align X to the bottom score label (`scoreX`) plus an offset to the right
|
||||||
|
float panelX = scoreX + 30.0f; // move ~30px right to align with score label
|
||||||
|
float panelW = statsW + 32.0f;
|
||||||
|
float panelY = gridY - panelH - holdGap;
|
||||||
|
// Move panel a bit higher for spacing (about half the extra height)
|
||||||
|
panelY -= extraH * 0.5f;
|
||||||
|
float labelX = panelX + 40.0f; // shift HOLD label ~30px to the right
|
||||||
|
float labelY = panelY + 8.0f;
|
||||||
|
|
||||||
|
if (holdPanelTex) {
|
||||||
|
int texW = 0, texH = 0;
|
||||||
|
SDL_QueryTexture(holdPanelTex, nullptr, nullptr, &texW, &texH);
|
||||||
|
if (texW > 0 && texH > 0) {
|
||||||
|
// If the texture is taller than the current panel, expand panelH
|
||||||
|
float texAspect = float(texH) / float(texW);
|
||||||
|
float desiredTexH = panelW * texAspect;
|
||||||
|
if (desiredTexH + 12.0f > panelH) {
|
||||||
|
panelH = desiredTexH + 12.0f;
|
||||||
|
// Recompute vertical placement after growing panelH
|
||||||
|
panelY = gridY - panelH - holdGap;
|
||||||
|
labelY = panelY + 8.0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fill panel width and compute destination height from texture aspect ratio
|
||||||
|
float texAspect = float(texH) / float(texW);
|
||||||
|
float dstW = panelW;
|
||||||
|
float dstH = dstW * texAspect * 1.2f;
|
||||||
|
// If texture height exceeds panel, expand panelH to fit texture comfortably
|
||||||
|
if (dstH + 12.0f > panelH) {
|
||||||
|
panelH = dstH + 12.0f;
|
||||||
|
panelY = gridY - panelH - holdGap;
|
||||||
|
labelY = panelY + 8.0f;
|
||||||
|
}
|
||||||
|
float dstX = panelX;
|
||||||
|
float dstY = panelY + (panelH - dstH) * 0.5f;
|
||||||
|
|
||||||
|
SDL_FRect panelDst{dstX, dstY, dstW, dstH};
|
||||||
|
SDL_SetTextureBlendMode(holdPanelTex, SDL_BLENDMODE_BLEND);
|
||||||
|
SDL_SetTextureScaleMode(holdPanelTex, SDL_SCALEMODE_LINEAR);
|
||||||
|
SDL_RenderTexture(renderer, holdPanelTex, nullptr, &panelDst);
|
||||||
|
} else {
|
||||||
|
// Fallback to filling panel area if texture metrics unavailable
|
||||||
|
SDL_SetRenderDrawColor(renderer, 12, 18, 32, 220);
|
||||||
|
SDL_FRect panelDst{panelX, panelY, panelW, panelH};
|
||||||
|
SDL_RenderFillRect(renderer, &panelDst);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
SDL_SetRenderDrawColor(renderer, 12, 18, 32, 220);
|
||||||
|
SDL_FRect panelDst{panelX, panelY, panelW, panelH};
|
||||||
|
SDL_RenderFillRect(renderer, &panelDst);
|
||||||
|
}
|
||||||
|
|
||||||
|
pixelFont->draw(renderer, labelX, labelY, "HOLDx", 1.0f, {255, 220, 0, 255});
|
||||||
|
|
||||||
|
if (game->held().type < PIECE_COUNT) {
|
||||||
|
float previewW = finalBlockSize * 0.6f * 4.0f;
|
||||||
|
float previewX = panelX + (panelW - previewW) * 0.5f;
|
||||||
|
float previewY = panelY + (panelH - holdBlockH) * 0.5f;
|
||||||
|
drawSmallPiece(renderer, blocksTex, static_cast<PieceType>(game->held().type), previewX, previewY, finalBlockSize * 0.6f);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pause overlay (suppressed when requested, e.g., countdown)
|
// Pause overlay (suppressed when requested, e.g., countdown)
|
||||||
|
|||||||
@ -24,6 +24,7 @@ public:
|
|||||||
SDL_Texture* statisticsPanelTex,
|
SDL_Texture* statisticsPanelTex,
|
||||||
SDL_Texture* scorePanelTex,
|
SDL_Texture* scorePanelTex,
|
||||||
SDL_Texture* nextPanelTex,
|
SDL_Texture* nextPanelTex,
|
||||||
|
SDL_Texture* holdPanelTex,
|
||||||
float logicalW,
|
float logicalW,
|
||||||
float logicalH,
|
float logicalH,
|
||||||
float logicalScale,
|
float logicalScale,
|
||||||
|
|||||||
287
src/graphics/challenge_mode.md
Normal file
@ -0,0 +1,287 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|
---
|
||||||
@ -518,6 +518,7 @@ void GameRenderer::renderPlayingState(
|
|||||||
SDL_Texture* statisticsPanelTex,
|
SDL_Texture* statisticsPanelTex,
|
||||||
SDL_Texture* scorePanelTex,
|
SDL_Texture* scorePanelTex,
|
||||||
SDL_Texture* nextPanelTex,
|
SDL_Texture* nextPanelTex,
|
||||||
|
SDL_Texture* holdPanelTex,
|
||||||
float logicalW,
|
float logicalW,
|
||||||
float logicalH,
|
float logicalH,
|
||||||
float logicalScale,
|
float logicalScale,
|
||||||
@ -1403,30 +1404,49 @@ void GameRenderer::renderPlayingState(
|
|||||||
pixelFont->draw(renderer, logicalW - 260, 10, gravityHud, 0.9f, {200, 200, 220, 255});
|
pixelFont->draw(renderer, logicalW - 260, 10, gravityHud, 0.9f, {200, 200, 220, 255});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hold piece (right side, above score dashboard)
|
// Hold panel background & label (always visible). Small preview renders only if a piece is held.
|
||||||
if (game->held().type < PIECE_COUNT) {
|
{
|
||||||
float holdLabelX = statsTextX;
|
float holdLabelX = statsTextX;
|
||||||
float holdY = statsY + statsH - 80.0f;
|
float holdY = statsY + statsH - 80.0f;
|
||||||
|
float holdBlockH = (finalBlockSize * 0.6f) * 6.0f;
|
||||||
|
const float holdGap = 18.0f;
|
||||||
|
float panelW = 120.0f;
|
||||||
|
float panelH = holdBlockH + 12.0f;
|
||||||
|
float panelX = holdLabelX + 40.0f;
|
||||||
|
float panelY = holdY - 6.0f;
|
||||||
|
|
||||||
if (scorePanelMetricsValid) {
|
if (scorePanelMetricsValid) {
|
||||||
const float holdGap = 18.0f;
|
// align panel to score panel width and position it above it
|
||||||
const float holdBlockH = (finalBlockSize * 0.6f) * 4.0f;
|
panelW = scorePanelWidth;
|
||||||
holdY = scorePanelTop - holdBlockH - holdGap;
|
panelX = scorePanelLeftX;
|
||||||
holdLabelX = statsTextX;
|
panelY = scorePanelTop - panelH - holdGap;
|
||||||
// Ensure HOLD block doesn't drift too far left if the score panel gets narrow.
|
// choose label X (left edge + padding)
|
||||||
holdLabelX = std::max(holdLabelX, scorePanelLeftX + 14.0f);
|
holdLabelX = panelX + 10.0f;
|
||||||
// If the score panel is extremely narrow, keep within its bounds.
|
// label Y inside panel
|
||||||
holdLabelX = std::min(holdLabelX, scorePanelLeftX + std::max(0.0f, scorePanelWidth - 90.0f));
|
holdY = panelY + 8.0f;
|
||||||
}
|
}
|
||||||
|
|
||||||
pixelFont->draw(renderer, holdLabelX, holdY, "HOLD", 1.0f, {255, 220, 0, 255});
|
if (holdPanelTex) {
|
||||||
drawSmallPiece(
|
SDL_FRect panelDst{panelX, panelY, panelW, panelH};
|
||||||
renderer,
|
SDL_SetTextureBlendMode(holdPanelTex, SDL_BLENDMODE_BLEND);
|
||||||
blocksTex,
|
SDL_SetTextureScaleMode(holdPanelTex, SDL_SCALEMODE_LINEAR);
|
||||||
static_cast<PieceType>(game->held().type),
|
SDL_RenderTexture(renderer, holdPanelTex, nullptr, &panelDst);
|
||||||
holdLabelX + 50.0f,
|
} else {
|
||||||
holdY + 2.0f,
|
// fallback: draw a dark panel rect so UI is visible even without texture
|
||||||
finalBlockSize * 0.6f
|
SDL_SetRenderDrawColor(renderer, 12, 18, 32, 220);
|
||||||
);
|
SDL_FRect panelDst{panelX, panelY, panelW, panelH};
|
||||||
|
SDL_RenderFillRect(renderer, &panelDst);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display "HOLD" label on right side
|
||||||
|
pixelFont->draw(renderer, holdLabelX + 56.0f, holdY + 4.0f, "HOLD", 1.0f, {255, 220, 0, 255});
|
||||||
|
|
||||||
|
if (game->held().type < PIECE_COUNT) {
|
||||||
|
// Draw small held preview inside the panel (centered)
|
||||||
|
float previewX = panelX + (panelW - (finalBlockSize * 0.6f * 4.0f)) * 0.5f;
|
||||||
|
float previewY = panelY + (panelH - holdBlockH) * 2.5f;
|
||||||
|
drawSmallPiece(renderer, blocksTex, static_cast<PieceType>(game->held().type), previewX, previewY, finalBlockSize * 0.6f);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pause overlay logic moved to renderPauseOverlay
|
// Pause overlay logic moved to renderPauseOverlay
|
||||||
|
|||||||
@ -24,6 +24,7 @@ public:
|
|||||||
SDL_Texture* statisticsPanelTex,
|
SDL_Texture* statisticsPanelTex,
|
||||||
SDL_Texture* scorePanelTex,
|
SDL_Texture* scorePanelTex,
|
||||||
SDL_Texture* nextPanelTex,
|
SDL_Texture* nextPanelTex,
|
||||||
|
SDL_Texture* holdPanelTex,
|
||||||
float logicalW,
|
float logicalW,
|
||||||
float logicalH,
|
float logicalH,
|
||||||
float logicalScale,
|
float logicalScale,
|
||||||
|
|||||||
@ -238,6 +238,7 @@ void PlayingState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect l
|
|||||||
ctx.statisticsPanelTex,
|
ctx.statisticsPanelTex,
|
||||||
ctx.scorePanelTex,
|
ctx.scorePanelTex,
|
||||||
ctx.nextPanelTex,
|
ctx.nextPanelTex,
|
||||||
|
ctx.holdPanelTex,
|
||||||
1200.0f, // LOGICAL_W
|
1200.0f, // LOGICAL_W
|
||||||
1000.0f, // LOGICAL_H
|
1000.0f, // LOGICAL_H
|
||||||
logicalScale,
|
logicalScale,
|
||||||
@ -325,6 +326,7 @@ void PlayingState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect l
|
|||||||
ctx.statisticsPanelTex,
|
ctx.statisticsPanelTex,
|
||||||
ctx.scorePanelTex,
|
ctx.scorePanelTex,
|
||||||
ctx.nextPanelTex,
|
ctx.nextPanelTex,
|
||||||
|
ctx.holdPanelTex,
|
||||||
1200.0f,
|
1200.0f,
|
||||||
1000.0f,
|
1000.0f,
|
||||||
logicalScale,
|
logicalScale,
|
||||||
|
|||||||
@ -43,6 +43,7 @@ struct StateContext {
|
|||||||
SDL_Texture* scorePanelTex = nullptr;
|
SDL_Texture* scorePanelTex = nullptr;
|
||||||
SDL_Texture* statisticsPanelTex = nullptr;
|
SDL_Texture* statisticsPanelTex = nullptr;
|
||||||
SDL_Texture* nextPanelTex = nullptr;
|
SDL_Texture* nextPanelTex = nullptr;
|
||||||
|
SDL_Texture* holdPanelTex = nullptr; // Background for the HOLD preview
|
||||||
SDL_Texture* mainScreenTex = nullptr;
|
SDL_Texture* mainScreenTex = nullptr;
|
||||||
int mainScreenW = 0;
|
int mainScreenW = 0;
|
||||||
int mainScreenH = 0;
|
int mainScreenH = 0;
|
||||||
|
|||||||