diff --git a/src/app/TetrisApp.cpp b/src/app/TetrisApp.cpp index 68db98c..c3f96ff 100644 --- a/src/app/TetrisApp.cpp +++ b/src/app/TetrisApp.cpp @@ -82,6 +82,75 @@ static const std::array COLORS = {{ SDL_Color{255, 160, 0, 255}, // L }}; +static std::string GetLevelStoryText(int level) { + int lvl = std::clamp(level, 1, 100); + + // Milestones + switch (lvl) { + case 1: return "Launch log: training run, light debris ahead."; + case 25: return "Checkpoint: dense field reported, shields ready."; + case 50: return "Midway brief: hull stress rising, stay sharp."; + case 75: return "Emergency corridor: comms unstable, proceed blind."; + case 100: return "Final anomaly: unknown mass ahead, hold course."; + default: break; + } + + struct Pool { int minL, maxL; std::vector lines; }; + static const std::vector pools = { + {1, 10, { + "Departure logged: light debris, stay on vector.", + "Training sector: minimal drift, keep sensors warm.", + "Calm approach: verify thrusters and nav locks.", + "Outer ring dust: watch for slow movers.", + "Clear lanes ahead: focus on smooth rotations." + }}, + {11, 25, { + "Asteroid belt thickening; micro-impacts likely.", + "Density rising: plot short burns only.", + "Field report: medium fragments, unpredictable spin.", + "Warning: overlapping paths, reduce horizontal drift.", + "Rock chorus ahead; keep payload stable." + }}, + {26, 40, { + "Unstable sector: abandoned relays drifting erratic.", + "Salvage echoes detected; debris wakes may tug.", + "Hull groans recorded; inert structures nearby.", + "Navigation buoys dark; trust instruments only.", + "Magnetic static rising; expect odd rotations." + }}, + {41, 60, { + "Core corridor: heavy asteroids, minimal clearance.", + "Impact risk high: armor checks recommended.", + "Dense stone flow; time burns carefully.", + "Grav eddies noted; blocks may drift late.", + "Core shards are brittle; expect sudden splits." + }}, + {61, 80, { + "Critical zone: alarms pinned, route unstable.", + "Emergency pattern: glide, then cut thrust.", + "Sensors flare; debris ionized, visibility low.", + "Thermals spiking; keep pieces tight and fast.", + "Silent channel; assume worst-case collision." + }}, + {81, 100, { + "Unknown space: signals warp, gravity unreliable.", + "Anomaly bloom ahead; shapes flicker unpredictably.", + "Final drift: void sings through hull plates.", + "Black sector: map useless, fly by instinct.", + "Edge of chart: nothing responds, just move." + }} + }; + + for (const auto& pool : pools) { + if (lvl >= pool.minL && lvl <= pool.maxL && !pool.lines.empty()) { + size_t idx = static_cast((lvl - pool.minL) % pool.lines.size()); + return pool.lines[idx]; + } + } + + return "Mission log update unavailable."; +} + struct TetrisApp::Impl { // Global collector for asset loading errors shown on the loading screen std::vector assetLoadErrors; @@ -199,6 +268,10 @@ struct TetrisApp::Impl { int countdownGoalAsteroids = 0; bool countdownAdvancesChallenge = false; double gameplayBackgroundClockMs = 0.0; + std::string challengeStoryText; + int challengeStoryLevel = 0; + float challengeStoryAlpha = 0.0f; + double challengeStoryClockMs = 0.0; // Challenge clear FX (celebratory board explosion before countdown) bool challengeClearFxActive = false; @@ -465,6 +538,9 @@ int TetrisApp::Impl::init() ctx.challengeClearFxElapsedMs = &challengeClearFxElapsedMs; ctx.challengeClearFxDurationMs = &challengeClearFxDurationMs; ctx.challengeClearFxOrder = &challengeClearFxOrder; + ctx.challengeStoryText = &challengeStoryText; + ctx.challengeStoryLevel = &challengeStoryLevel; + ctx.challengeStoryAlpha = &challengeStoryAlpha; ctx.playerName = &playerName; ctx.fullscreenFlag = &isFullscreen; ctx.applyFullscreen = [this](bool enable) { @@ -585,6 +661,14 @@ void TetrisApp::Impl::runLoop() } }; + auto captureChallengeStory = [this](int level) { + int lvl = std::clamp(level, 1, 100); + challengeStoryLevel = lvl; + challengeStoryText = GetLevelStoryText(lvl); + challengeStoryClockMs = 0.0; + challengeStoryAlpha = 0.0f; + }; + auto startChallengeClearFx = [this](int nextLevel) { challengeClearFxOrder.clear(); const auto& boardRef = game->boardRef(); @@ -926,6 +1010,36 @@ void TetrisApp::Impl::runLoop() if (frameMs > 100.0) frameMs = 100.0; gameplayBackgroundClockMs += frameMs; + auto clearChallengeStory = [this]() { + challengeStoryText.clear(); + challengeStoryLevel = 0; + challengeStoryAlpha = 0.0f; + challengeStoryClockMs = 0.0; + }; + + // Update challenge story fade/timeout + if (state == AppState::Playing && game && game->getMode() == GameMode::Challenge && !challengeStoryText.empty()) { + const double fadeInMs = 320.0; + const double holdMs = 3200.0; + const double fadeOutMs = 900.0; + const double totalMs = fadeInMs + holdMs + fadeOutMs; + challengeStoryClockMs += frameMs; + if (challengeStoryClockMs >= totalMs) { + clearChallengeStory(); + } else { + double a = 1.0; + if (challengeStoryClockMs < fadeInMs) { + a = challengeStoryClockMs / fadeInMs; + } else if (challengeStoryClockMs > fadeInMs + holdMs) { + double t = challengeStoryClockMs - (fadeInMs + holdMs); + a = std::max(0.0, 1.0 - t / fadeOutMs); + } + challengeStoryAlpha = static_cast(std::clamp(a, 0.0, 1.0)); + } + } else { + clearChallengeStory(); + } + if (challengeClearFxActive) { challengeClearFxElapsedMs += frameMs; if (challengeClearFxElapsedMs >= challengeClearFxDurationMs) { @@ -940,6 +1054,7 @@ void TetrisApp::Impl::runLoop() gameplayCountdownSource = CountdownSource::ChallengeLevel; countdownLevel = challengeClearFxNextLevel; countdownGoalAsteroids = challengeClearFxNextLevel; + captureChallengeStory(countdownLevel); countdownAdvancesChallenge = false; // already advanced gameplayCountdownActive = true; menuPlayCountdownArmed = false; @@ -1343,6 +1458,12 @@ void TetrisApp::Impl::runLoop() : CountdownSource::MenuStart; countdownLevel = game ? game->challengeLevel() : 1; countdownGoalAsteroids = countdownLevel; + if (gameplayCountdownSource == CountdownSource::ChallengeLevel) { + captureChallengeStory(countdownLevel); + } else { + challengeStoryText.clear(); + challengeStoryLevel = 0; + } countdownAdvancesChallenge = false; menuPlayCountdownArmed = true; gameplayCountdownActive = false; @@ -1376,6 +1497,12 @@ void TetrisApp::Impl::runLoop() : CountdownSource::MenuStart; countdownLevel = game ? game->challengeLevel() : 1; countdownGoalAsteroids = countdownLevel; + if (gameplayCountdownSource == CountdownSource::ChallengeLevel) { + captureChallengeStory(countdownLevel); + } else { + challengeStoryText.clear(); + challengeStoryLevel = 0; + } countdownAdvancesChallenge = false; gameplayCountdownActive = true; menuPlayCountdownArmed = false; diff --git a/src/graphics/renderers/GameRenderer.cpp b/src/graphics/renderers/GameRenderer.cpp index 154cf65..d549a7e 100644 --- a/src/graphics/renderers/GameRenderer.cpp +++ b/src/graphics/renderers/GameRenderer.cpp @@ -7,6 +7,8 @@ #include #include #include +#include +#include #include #include #include @@ -610,7 +612,9 @@ void GameRenderer::renderPlayingState( bool challengeClearFxActive, const std::vector* challengeClearFxOrder, double challengeClearFxElapsedMs, - double challengeClearFxDurationMs + double challengeClearFxDurationMs, + const std::string* challengeStoryText, + float challengeStoryAlpha ) { if (!game || !pixelFont) return; @@ -1702,6 +1706,51 @@ void GameRenderer::renderPlayingState( pixelFont->draw(renderer, statsTextX, baseY + line.offsetY, line.text, line.scale, line.color); } + // Challenge story / briefing line near level indicator + if (challengeStoryText && !challengeStoryText->empty() && challengeStoryAlpha > 0.0f && game->getMode() == GameMode::Challenge) { + float alpha = std::clamp(challengeStoryAlpha, 0.0f, 1.0f); + SDL_Color storyColor{160, 220, 255, static_cast(std::lround(210.0f * alpha))}; + SDL_Color shadowColor{0, 0, 0, static_cast(std::lround(120.0f * alpha))}; + + auto drawWrapped = [&](const std::string& text, float x, float y, float maxW, float scale, SDL_Color color) { + std::istringstream iss(text); + std::string word; + std::string line; + float cursorY = y; + int lastH = 0; + while (iss >> word) { + std::string candidate = line.empty() ? word : (line + " " + word); + int w = 0, h = 0; + pixelFont->measure(candidate, scale, w, h); + if (w > maxW && !line.empty()) { + pixelFont->draw(renderer, x + 1.0f, cursorY + 1.0f, line, scale, shadowColor); + pixelFont->draw(renderer, x, cursorY, line, scale, color); + cursorY += h + 4.0f; + line = word; + lastH = h; + } else { + line = candidate; + lastH = h; + } + } + if (!line.empty()) { + pixelFont->draw(renderer, x + 1.0f, cursorY + 1.0f, line, scale, shadowColor); + pixelFont->draw(renderer, x, cursorY, line, scale, color); + cursorY += lastH + 4.0f; + } + }; + + float storyX = statsTextX; + float storyY = baseY + 112.0f; + float maxW = 230.0f; + if (scorePanelMetricsValid && scorePanelWidth > 40.0f) { + storyX = scorePanelLeftX + 14.0f; + maxW = std::max(160.0f, scorePanelWidth - 28.0f); + } + + drawWrapped(*challengeStoryText, storyX, storyY, maxW, 0.7f, storyColor); + } + if (debugEnabled) { pixelFont->draw(renderer, logicalW - 260, 10, gravityHud, 0.9f, {200, 200, 220, 255}); } diff --git a/src/graphics/renderers/GameRenderer.h b/src/graphics/renderers/GameRenderer.h index 4c87769..f8d360c 100644 --- a/src/graphics/renderers/GameRenderer.h +++ b/src/graphics/renderers/GameRenderer.h @@ -1,6 +1,7 @@ #pragma once #include #include +#include #include "../../gameplay/core/Game.h" // Forward declarations @@ -36,7 +37,9 @@ public: bool challengeClearFxActive = false, const std::vector* challengeClearFxOrder = nullptr, double challengeClearFxElapsedMs = 0.0, - double challengeClearFxDurationMs = 0.0 + double challengeClearFxDurationMs = 0.0, + const std::string* challengeStoryText = nullptr, + float challengeStoryAlpha = 0.0f ); // Render the pause overlay (full screen) diff --git a/src/states/PlayingState.cpp b/src/states/PlayingState.cpp index b841e6e..271c2ee 100644 --- a/src/states/PlayingState.cpp +++ b/src/states/PlayingState.cpp @@ -269,7 +269,9 @@ void PlayingState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect l challengeClearFx, challengeClearOrder, challengeClearElapsed, - challengeClearDuration + challengeClearDuration, + ctx.challengeStoryText, + ctx.challengeStoryAlpha ? *ctx.challengeStoryAlpha : 0.0f ); // Reset to screen @@ -363,7 +365,9 @@ void PlayingState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect l challengeClearFx, challengeClearOrder, challengeClearElapsed, - challengeClearDuration + challengeClearDuration, + ctx.challengeStoryText, + ctx.challengeStoryAlpha ? *ctx.challengeStoryAlpha : 0.0f ); } } diff --git a/src/states/State.h b/src/states/State.h index ebbc25b..a9e0503 100644 --- a/src/states/State.h +++ b/src/states/State.h @@ -73,6 +73,9 @@ struct StateContext { double* challengeClearFxElapsedMs = nullptr; double* challengeClearFxDurationMs = nullptr; std::vector* challengeClearFxOrder = nullptr; + std::string* challengeStoryText = nullptr; // Per-level briefing string for Challenge mode + int* challengeStoryLevel = nullptr; // Cached level for the current story line + float* challengeStoryAlpha = nullptr; // Current render alpha for story text fade std::string* playerName = nullptr; // Shared player name buffer for highscores/options bool* fullscreenFlag = nullptr; // Tracks current fullscreen state when available std::function applyFullscreen; // Allows states to request fullscreen changes