2256 lines
98 KiB
C++
2256 lines
98 KiB
C++
// TetrisApp.cpp - Main application runtime split out from main.cpp.
|
|
//
|
|
// This file is intentionally "orchestration-heavy": it wires together SDL, audio,
|
|
// asset loading, and the state machine. Keep gameplay mechanics in the gameplay/
|
|
// and states/ modules.
|
|
|
|
#include "app/TetrisApp.h"
|
|
|
|
#include <SDL3/SDL.h>
|
|
#include <SDL3_ttf/SDL_ttf.h>
|
|
|
|
#include <algorithm>
|
|
#include <array>
|
|
#include <atomic>
|
|
#include <cmath>
|
|
#include <cstdio>
|
|
#include <cstdlib>
|
|
#include <filesystem>
|
|
#include <mutex>
|
|
#include <random>
|
|
#include <sstream>
|
|
#include <string>
|
|
#include <thread>
|
|
#include <vector>
|
|
|
|
#include "app/AssetLoader.h"
|
|
#include "app/BackgroundManager.h"
|
|
#include "app/Fireworks.h"
|
|
#include "app/TextureLoader.h"
|
|
|
|
#include "audio/Audio.h"
|
|
#include "audio/MenuWrappers.h"
|
|
#include "audio/SoundEffect.h"
|
|
|
|
#include "core/Config.h"
|
|
#include "core/Settings.h"
|
|
#include "core/state/StateManager.h"
|
|
|
|
#include "gameplay/core/Game.h"
|
|
#include "gameplay/coop/CoopGame.h"
|
|
#include "gameplay/effects/LineEffect.h"
|
|
|
|
#include "graphics/effects/SpaceWarp.h"
|
|
#include "graphics/effects/Starfield.h"
|
|
#include "graphics/effects/Starfield3D.h"
|
|
#include "graphics/renderers/GameRenderer.h"
|
|
#include "graphics/renderers/RenderPrimitives.h"
|
|
#include "graphics/ui/Font.h"
|
|
#include "graphics/ui/HelpOverlay.h"
|
|
|
|
#include "persistence/Scores.h"
|
|
|
|
#include "states/LevelSelectorState.h"
|
|
#include "states/LoadingManager.h"
|
|
#include "states/LoadingState.h"
|
|
#include "states/MenuState.h"
|
|
#include "states/OptionsState.h"
|
|
#include "states/PlayingState.h"
|
|
#include "states/State.h"
|
|
|
|
#include "ui/BottomMenu.h"
|
|
#include "../resources/AssetPaths.h"
|
|
#include "ui/MenuLayout.h"
|
|
|
|
#include "utils/ImagePathResolver.h"
|
|
|
|
// ---------- Game config ----------
|
|
static constexpr int LOGICAL_W = 1200;
|
|
static constexpr int LOGICAL_H = 1000;
|
|
static constexpr int WELL_W = Game::COLS * Game::TILE;
|
|
static constexpr int WELL_H = Game::ROWS * Game::TILE;
|
|
|
|
#include "ui/UIConstants.h"
|
|
|
|
static const std::array<SDL_Color, PIECE_COUNT + 1> COLORS = {{
|
|
SDL_Color{20, 20, 26, 255}, // 0 empty
|
|
SDL_Color{0, 255, 255, 255}, // I
|
|
SDL_Color{255, 255, 0, 255}, // O
|
|
SDL_Color{160, 0, 255, 255}, // T
|
|
SDL_Color{0, 255, 0, 255}, // S
|
|
SDL_Color{255, 0, 0, 255}, // Z
|
|
SDL_Color{0, 0, 255, 255}, // J
|
|
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<std::string> lines; };
|
|
static const std::vector<Pool> 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<size_t>((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<std::string> assetLoadErrors;
|
|
std::mutex assetLoadErrorsMutex;
|
|
// Loading counters for progress UI and debug overlay
|
|
std::atomic<int> totalLoadingTasks{0};
|
|
std::atomic<int> loadedTasks{0};
|
|
std::string currentLoadingFile;
|
|
std::mutex currentLoadingMutex;
|
|
|
|
// Intro/Menu shared state (wired into StateContext as pointers)
|
|
double logoAnimCounter = 0.0;
|
|
bool showSettingsPopup = false;
|
|
bool showHelpOverlay = false;
|
|
bool showExitConfirmPopup = false;
|
|
int exitPopupSelectedButton = 1; // 0 = YES, 1 = NO
|
|
bool musicEnabled = true;
|
|
int hoveredButton = -1; // -1 = none, 0 = play, 1 = level, 2 = settings
|
|
bool isNewHighScore = false;
|
|
std::string playerName;
|
|
bool helpOverlayPausedGame = false;
|
|
|
|
SDL_Window* window = nullptr;
|
|
SDL_Renderer* renderer = nullptr;
|
|
|
|
AssetLoader assetLoader;
|
|
std::unique_ptr<LoadingManager> loadingManager;
|
|
std::unique_ptr<TextureLoader> textureLoader;
|
|
|
|
FontAtlas pixelFont;
|
|
FontAtlas font;
|
|
|
|
ScoreManager scores;
|
|
std::atomic<bool> scoresLoadComplete{false};
|
|
std::jthread scoreLoader;
|
|
std::jthread menuTrackLoader;
|
|
|
|
Starfield starfield;
|
|
Starfield3D starfield3D;
|
|
SpaceWarp spaceWarp;
|
|
SpaceWarpFlightMode warpFlightMode = SpaceWarpFlightMode::Forward;
|
|
bool warpAutoPilotEnabled = true;
|
|
|
|
LineEffect lineEffect;
|
|
|
|
SDL_Texture* logoTex = nullptr;
|
|
SDL_Texture* logoSmallTex = nullptr;
|
|
int logoSmallW = 0;
|
|
int logoSmallH = 0;
|
|
SDL_Texture* backgroundTex = nullptr;
|
|
SDL_Texture* mainScreenTex = nullptr;
|
|
int mainScreenW = 0;
|
|
int mainScreenH = 0;
|
|
|
|
SDL_Texture* blocksTex = nullptr;
|
|
SDL_Texture* asteroidsTex = nullptr;
|
|
SDL_Texture* scorePanelTex = nullptr;
|
|
SDL_Texture* statisticsPanelTex = nullptr;
|
|
SDL_Texture* nextPanelTex = nullptr;
|
|
SDL_Texture* holdPanelTex = nullptr;
|
|
|
|
BackgroundManager levelBackgrounds;
|
|
int startLevelSelection = 0;
|
|
|
|
// Music loading tracking
|
|
int totalTracks = 0;
|
|
int currentTrackLoading = 0;
|
|
bool musicLoaded = false;
|
|
bool musicStarted = false;
|
|
bool musicLoadingStarted = false;
|
|
|
|
// Loader control: execute incrementally on main thread to avoid SDL threading issues
|
|
std::atomic_bool loadingStarted{false};
|
|
std::atomic_bool loadingComplete{false};
|
|
std::atomic<size_t> loadingStep{0};
|
|
|
|
std::unique_ptr<Game> game;
|
|
std::unique_ptr<CoopGame> coopGame;
|
|
std::vector<std::string> singleSounds;
|
|
std::vector<std::string> doubleSounds;
|
|
std::vector<std::string> tripleSounds;
|
|
std::vector<std::string> tetrisSounds;
|
|
bool suppressLineVoiceForLevelUp = false;
|
|
bool skipNextLevelUpJingle = false;
|
|
|
|
AppState state = AppState::Loading;
|
|
double loadingProgress = 0.0;
|
|
Uint64 loadStart = 0;
|
|
bool running = true;
|
|
bool isFullscreen = false;
|
|
bool leftHeld = false;
|
|
bool rightHeld = false;
|
|
bool p1LeftHeld = false;
|
|
bool p1RightHeld = false;
|
|
bool p2LeftHeld = false;
|
|
bool p2RightHeld = false;
|
|
double moveTimerMs = 0.0;
|
|
double p1MoveTimerMs = 0.0;
|
|
double p2MoveTimerMs = 0.0;
|
|
double DAS = 170.0;
|
|
double ARR = 40.0;
|
|
SDL_Rect logicalVP{0, 0, LOGICAL_W, LOGICAL_H};
|
|
float logicalScale = 1.f;
|
|
Uint64 lastMs = 0;
|
|
|
|
enum class MenuFadePhase { None, FadeOut, FadeIn };
|
|
MenuFadePhase menuFadePhase = MenuFadePhase::None;
|
|
double menuFadeClockMs = 0.0;
|
|
float menuFadeAlpha = 0.0f;
|
|
double MENU_PLAY_FADE_DURATION_MS = 450.0;
|
|
AppState menuFadeTarget = AppState::Menu;
|
|
|
|
enum class CountdownSource { MenuStart, ChallengeLevel };
|
|
bool menuPlayCountdownArmed = false;
|
|
bool gameplayCountdownActive = false;
|
|
double gameplayCountdownElapsed = 0.0;
|
|
int gameplayCountdownIndex = 0;
|
|
double GAMEPLAY_COUNTDOWN_STEP_MS = 400.0;
|
|
std::array<const char*, 4> GAMEPLAY_COUNTDOWN_LABELS = { "3", "2", "1", "START" };
|
|
CountdownSource gameplayCountdownSource = CountdownSource::MenuStart;
|
|
int countdownLevel = 0;
|
|
int countdownGoalAsteroids = 0;
|
|
bool countdownAdvancesChallenge = false;
|
|
bool challengeCountdownWaitingForSpace = 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;
|
|
double challengeClearFxElapsedMs = 0.0;
|
|
double challengeClearFxDurationMs = 0.0;
|
|
int challengeClearFxNextLevel = 0;
|
|
std::vector<int> challengeClearFxOrder;
|
|
std::mt19937 challengeClearFxRng{std::random_device{}()};
|
|
|
|
std::unique_ptr<StateManager> stateMgr;
|
|
StateContext ctx{};
|
|
std::unique_ptr<LoadingState> loadingState;
|
|
std::unique_ptr<MenuState> menuState;
|
|
std::unique_ptr<OptionsState> optionsState;
|
|
std::unique_ptr<LevelSelectorState> levelSelectorState;
|
|
std::unique_ptr<PlayingState> playingState;
|
|
|
|
int init();
|
|
void runLoop();
|
|
void shutdown();
|
|
};
|
|
|
|
TetrisApp::TetrisApp()
|
|
: impl_(std::make_unique<Impl>())
|
|
{
|
|
}
|
|
|
|
TetrisApp::~TetrisApp() = default;
|
|
|
|
int TetrisApp::run()
|
|
{
|
|
const int initRc = impl_->init();
|
|
if (initRc != 0) {
|
|
impl_->shutdown();
|
|
return initRc;
|
|
}
|
|
|
|
impl_->runLoop();
|
|
impl_->shutdown();
|
|
return 0;
|
|
}
|
|
|
|
int TetrisApp::Impl::init()
|
|
{
|
|
// Initialize random seed for procedural effects
|
|
srand(static_cast<unsigned int>(SDL_GetTicks()));
|
|
|
|
// Load settings
|
|
Settings::instance().load();
|
|
|
|
// Sync shared variables with settings
|
|
musicEnabled = Settings::instance().isMusicEnabled();
|
|
playerName = Settings::instance().getPlayerName();
|
|
if (playerName.empty()) playerName = "Player";
|
|
|
|
// Apply sound settings to manager
|
|
SoundEffectManager::instance().setEnabled(Settings::instance().isSoundEnabled());
|
|
|
|
int sdlInitRes = SDL_Init(SDL_INIT_VIDEO | SDL_INIT_AUDIO);
|
|
if (sdlInitRes < 0)
|
|
{
|
|
SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "SDL_Init failed: %s", SDL_GetError());
|
|
return 1;
|
|
}
|
|
int ttfInitRes = TTF_Init();
|
|
if (ttfInitRes < 0)
|
|
{
|
|
SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "TTF_Init failed");
|
|
SDL_Quit();
|
|
return 1;
|
|
}
|
|
|
|
SDL_WindowFlags windowFlags = SDL_WINDOW_RESIZABLE;
|
|
if (Settings::instance().isFullscreen()) {
|
|
windowFlags |= SDL_WINDOW_FULLSCREEN;
|
|
}
|
|
|
|
window = SDL_CreateWindow("SpaceTris (SDL3)", LOGICAL_W, LOGICAL_H, windowFlags);
|
|
if (!window)
|
|
{
|
|
SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "SDL_CreateWindow failed: %s", SDL_GetError());
|
|
TTF_Quit();
|
|
SDL_Quit();
|
|
return 1;
|
|
}
|
|
renderer = SDL_CreateRenderer(window, nullptr);
|
|
if (!renderer)
|
|
{
|
|
SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "SDL_CreateRenderer failed: %s", SDL_GetError());
|
|
SDL_DestroyWindow(window);
|
|
window = nullptr;
|
|
TTF_Quit();
|
|
SDL_Quit();
|
|
return 1;
|
|
}
|
|
SDL_SetRenderVSync(renderer, 1);
|
|
|
|
if (const char* basePathRaw = SDL_GetBasePath()) {
|
|
std::filesystem::path exeDir(basePathRaw);
|
|
AssetPath::setBasePath(exeDir.string());
|
|
#if defined(__APPLE__)
|
|
// On macOS bundles launched from Finder start in /, so re-root relative paths.
|
|
std::error_code ec;
|
|
std::filesystem::current_path(exeDir, ec);
|
|
if (ec) {
|
|
SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION,
|
|
"Failed to set working directory to %s: %s",
|
|
exeDir.string().c_str(), ec.message().c_str());
|
|
}
|
|
#endif
|
|
} else {
|
|
SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION,
|
|
"SDL_GetBasePath() failed; asset lookups rely on current directory: %s",
|
|
SDL_GetError());
|
|
}
|
|
|
|
// Asset loader (creates SDL_Textures on the main thread)
|
|
assetLoader.init(renderer);
|
|
loadingManager = std::make_unique<LoadingManager>(&assetLoader);
|
|
|
|
// Legacy image loader (used only as a fallback when AssetLoader misses)
|
|
textureLoader = std::make_unique<TextureLoader>(
|
|
loadedTasks,
|
|
currentLoadingFile,
|
|
currentLoadingMutex,
|
|
assetLoadErrors,
|
|
assetLoadErrorsMutex);
|
|
|
|
// Load scores asynchronously but keep the worker alive until shutdown
|
|
scoreLoader = std::jthread([this]() {
|
|
scores.load();
|
|
scoresLoadComplete.store(true, std::memory_order_release);
|
|
});
|
|
|
|
starfield.init(200, LOGICAL_W, LOGICAL_H);
|
|
starfield3D.init(LOGICAL_W, LOGICAL_H, 200);
|
|
spaceWarp.init(LOGICAL_W, LOGICAL_H, 420);
|
|
spaceWarp.setFlightMode(warpFlightMode);
|
|
warpAutoPilotEnabled = true;
|
|
spaceWarp.setAutoPilotEnabled(true);
|
|
|
|
// Initialize line clearing effects
|
|
lineEffect.init(renderer);
|
|
|
|
game = std::make_unique<Game>(startLevelSelection);
|
|
game->setGravityGlobalMultiplier(Config::Gameplay::GRAVITY_SPEED_MULTIPLIER);
|
|
game->reset(startLevelSelection);
|
|
|
|
coopGame = std::make_unique<CoopGame>(startLevelSelection);
|
|
|
|
// Define voice line banks for gameplay callbacks
|
|
singleSounds = {"well_played", "smooth_clear", "great_move"};
|
|
doubleSounds = {"nice_combo", "you_fire", "keep_that_ryhtm"};
|
|
tripleSounds = {"impressive", "triple_strike"};
|
|
tetrisSounds = {"amazing", "you_re_unstoppable", "boom_tetris", "wonderful"};
|
|
suppressLineVoiceForLevelUp = false;
|
|
|
|
auto playVoiceCue = [this](int linesCleared) {
|
|
const std::vector<std::string>* bank = nullptr;
|
|
switch (linesCleared) {
|
|
case 1: bank = &singleSounds; break;
|
|
case 2: bank = &doubleSounds; break;
|
|
case 3: bank = &tripleSounds; break;
|
|
default:
|
|
if (linesCleared >= 4) {
|
|
bank = &tetrisSounds;
|
|
}
|
|
break;
|
|
}
|
|
if (bank && !bank->empty()) {
|
|
SoundEffectManager::instance().playRandomSound(*bank, 1.0f);
|
|
}
|
|
};
|
|
|
|
game->setSoundCallback([this, playVoiceCue](int linesCleared) {
|
|
if (linesCleared <= 0) {
|
|
return;
|
|
}
|
|
|
|
SoundEffectManager::instance().playSound("clear_line", 1.0f);
|
|
|
|
if (!suppressLineVoiceForLevelUp) {
|
|
playVoiceCue(linesCleared);
|
|
}
|
|
suppressLineVoiceForLevelUp = false;
|
|
});
|
|
|
|
// Keep co-op line-clear SFX behavior identical to classic.
|
|
coopGame->setSoundCallback([this, playVoiceCue](int linesCleared) {
|
|
if (linesCleared <= 0) {
|
|
return;
|
|
}
|
|
|
|
SoundEffectManager::instance().playSound("clear_line", 1.0f);
|
|
|
|
if (!suppressLineVoiceForLevelUp) {
|
|
playVoiceCue(linesCleared);
|
|
}
|
|
suppressLineVoiceForLevelUp = false;
|
|
});
|
|
|
|
game->setLevelUpCallback([this](int /*newLevel*/) {
|
|
if (skipNextLevelUpJingle) {
|
|
skipNextLevelUpJingle = false;
|
|
} else {
|
|
SoundEffectManager::instance().playSound("new_level", 1.0f);
|
|
SoundEffectManager::instance().playSound("lets_go", 1.0f);
|
|
}
|
|
suppressLineVoiceForLevelUp = true;
|
|
});
|
|
|
|
game->setAsteroidDestroyedCallback([](AsteroidType /*type*/) {
|
|
SoundEffectManager::instance().playSound("asteroid_destroy", 0.9f);
|
|
});
|
|
|
|
state = AppState::Loading;
|
|
loadingProgress = 0.0;
|
|
loadStart = SDL_GetTicks();
|
|
running = true;
|
|
isFullscreen = Settings::instance().isFullscreen();
|
|
leftHeld = false;
|
|
rightHeld = false;
|
|
p1LeftHeld = p1RightHeld = p2LeftHeld = p2RightHeld = false;
|
|
moveTimerMs = 0;
|
|
p1MoveTimerMs = 0.0;
|
|
p2MoveTimerMs = 0.0;
|
|
DAS = 170.0;
|
|
ARR = 40.0;
|
|
logicalVP = SDL_Rect{0, 0, LOGICAL_W, LOGICAL_H};
|
|
logicalScale = 1.f;
|
|
lastMs = SDL_GetPerformanceCounter();
|
|
|
|
menuFadePhase = MenuFadePhase::None;
|
|
menuFadeClockMs = 0.0;
|
|
menuFadeAlpha = 0.0f;
|
|
MENU_PLAY_FADE_DURATION_MS = 450.0;
|
|
menuFadeTarget = AppState::Menu;
|
|
menuPlayCountdownArmed = false;
|
|
gameplayCountdownActive = false;
|
|
gameplayCountdownElapsed = 0.0;
|
|
gameplayCountdownIndex = 0;
|
|
GAMEPLAY_COUNTDOWN_STEP_MS = 400.0;
|
|
GAMEPLAY_COUNTDOWN_LABELS = { "3", "2", "1", "START" };
|
|
gameplayBackgroundClockMs = 0.0;
|
|
|
|
// Instantiate state manager
|
|
stateMgr = std::make_unique<StateManager>(state);
|
|
|
|
// Prepare shared context for states
|
|
ctx = StateContext{};
|
|
ctx.stateManager = stateMgr.get();
|
|
ctx.game = game.get();
|
|
ctx.coopGame = coopGame.get();
|
|
ctx.scores = nullptr;
|
|
ctx.starfield = &starfield;
|
|
ctx.starfield3D = &starfield3D;
|
|
ctx.font = &font;
|
|
ctx.pixelFont = &pixelFont;
|
|
ctx.lineEffect = &lineEffect;
|
|
ctx.logoTex = logoTex;
|
|
ctx.logoSmallTex = logoSmallTex;
|
|
ctx.logoSmallW = logoSmallW;
|
|
ctx.logoSmallH = logoSmallH;
|
|
ctx.backgroundTex = nullptr;
|
|
ctx.asteroidsTex = asteroidsTex;
|
|
ctx.blocksTex = blocksTex;
|
|
ctx.scorePanelTex = scorePanelTex;
|
|
ctx.statisticsPanelTex = statisticsPanelTex;
|
|
ctx.nextPanelTex = nextPanelTex;
|
|
ctx.mainScreenTex = mainScreenTex;
|
|
ctx.mainScreenW = mainScreenW;
|
|
ctx.mainScreenH = mainScreenH;
|
|
ctx.musicEnabled = &musicEnabled;
|
|
ctx.startLevelSelection = &startLevelSelection;
|
|
ctx.hoveredButton = &hoveredButton;
|
|
ctx.showSettingsPopup = &showSettingsPopup;
|
|
ctx.showHelpOverlay = &showHelpOverlay;
|
|
ctx.showExitConfirmPopup = &showExitConfirmPopup;
|
|
ctx.exitPopupSelectedButton = &exitPopupSelectedButton;
|
|
ctx.gameplayCountdownActive = &gameplayCountdownActive;
|
|
ctx.menuPlayCountdownArmed = &menuPlayCountdownArmed;
|
|
ctx.skipNextLevelUpJingle = &skipNextLevelUpJingle;
|
|
ctx.challengeClearFxActive = &challengeClearFxActive;
|
|
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) {
|
|
SDL_SetWindowFullscreen(window, enable ? SDL_WINDOW_FULLSCREEN : 0);
|
|
isFullscreen = enable;
|
|
};
|
|
ctx.queryFullscreen = [this]() -> bool {
|
|
return (SDL_GetWindowFlags(window) & SDL_WINDOW_FULLSCREEN) != 0;
|
|
};
|
|
ctx.requestQuit = [this]() {
|
|
running = false;
|
|
};
|
|
|
|
auto beginStateFade = [this](AppState targetState, bool armGameplayCountdown) {
|
|
if (!ctx.stateManager) {
|
|
return;
|
|
}
|
|
if (state == targetState) {
|
|
return;
|
|
}
|
|
if (menuFadePhase != MenuFadePhase::None) {
|
|
return;
|
|
}
|
|
|
|
menuFadePhase = MenuFadePhase::FadeOut;
|
|
menuFadeClockMs = 0.0;
|
|
menuFadeAlpha = 0.0f;
|
|
menuFadeTarget = targetState;
|
|
menuPlayCountdownArmed = armGameplayCountdown;
|
|
gameplayCountdownActive = false;
|
|
gameplayCountdownIndex = 0;
|
|
gameplayCountdownElapsed = 0.0;
|
|
|
|
if (!armGameplayCountdown) {
|
|
if (game) {
|
|
game->setPaused(false);
|
|
}
|
|
}
|
|
};
|
|
|
|
auto startMenuPlayTransition = [this, beginStateFade]() {
|
|
if (!ctx.stateManager) {
|
|
return;
|
|
}
|
|
if (state != AppState::Menu) {
|
|
state = AppState::Playing;
|
|
ctx.stateManager->setState(state);
|
|
return;
|
|
}
|
|
beginStateFade(AppState::Playing, true);
|
|
};
|
|
ctx.startPlayTransition = startMenuPlayTransition;
|
|
|
|
auto requestStateFade = [this, startMenuPlayTransition, beginStateFade](AppState targetState) {
|
|
if (!ctx.stateManager) {
|
|
return;
|
|
}
|
|
if (targetState == AppState::Playing) {
|
|
startMenuPlayTransition();
|
|
return;
|
|
}
|
|
beginStateFade(targetState, false);
|
|
};
|
|
ctx.requestFadeTransition = requestStateFade;
|
|
|
|
loadingState = std::make_unique<LoadingState>(ctx);
|
|
menuState = std::make_unique<MenuState>(ctx);
|
|
optionsState = std::make_unique<OptionsState>(ctx);
|
|
levelSelectorState = std::make_unique<LevelSelectorState>(ctx);
|
|
playingState = std::make_unique<PlayingState>(ctx);
|
|
|
|
stateMgr->registerHandler(AppState::Loading, [this](const SDL_Event& e){ loadingState->handleEvent(e); });
|
|
stateMgr->registerOnEnter(AppState::Loading, [this](){ loadingState->onEnter(); loadingStarted.store(true); });
|
|
stateMgr->registerOnExit(AppState::Loading, [this](){ loadingState->onExit(); });
|
|
|
|
stateMgr->registerHandler(AppState::Menu, [this](const SDL_Event& e){ menuState->handleEvent(e); });
|
|
stateMgr->registerOnEnter(AppState::Menu, [this](){ menuState->onEnter(); });
|
|
stateMgr->registerOnExit(AppState::Menu, [this](){ menuState->onExit(); });
|
|
|
|
stateMgr->registerHandler(AppState::Options, [this](const SDL_Event& e){ optionsState->handleEvent(e); });
|
|
stateMgr->registerOnEnter(AppState::Options, [this](){ optionsState->onEnter(); });
|
|
stateMgr->registerOnExit(AppState::Options, [this](){ optionsState->onExit(); });
|
|
|
|
stateMgr->registerHandler(AppState::LevelSelector, [this](const SDL_Event& e){ levelSelectorState->handleEvent(e); });
|
|
stateMgr->registerOnEnter(AppState::LevelSelector, [this](){ levelSelectorState->onEnter(); });
|
|
stateMgr->registerOnExit(AppState::LevelSelector, [this](){ levelSelectorState->onExit(); });
|
|
|
|
stateMgr->registerHandler(AppState::Playing, [this](const SDL_Event& e){ playingState->handleEvent(e); });
|
|
stateMgr->registerOnEnter(AppState::Playing, [this](){ playingState->onEnter(); });
|
|
stateMgr->registerOnExit(AppState::Playing, [this](){ playingState->onExit(); });
|
|
|
|
loadingState->onEnter();
|
|
loadingStarted.store(true);
|
|
|
|
return 0;
|
|
}
|
|
|
|
void TetrisApp::Impl::runLoop()
|
|
{
|
|
auto ensureScoresLoaded = [this]() {
|
|
if (scoreLoader.joinable()) {
|
|
scoreLoader.join();
|
|
}
|
|
if (!ctx.scores) {
|
|
ctx.scores = &scores;
|
|
}
|
|
};
|
|
|
|
auto startMenuPlayTransition = [this]() {
|
|
if (ctx.startPlayTransition) {
|
|
ctx.startPlayTransition();
|
|
}
|
|
};
|
|
|
|
auto requestStateFade = [this](AppState targetState) {
|
|
if (ctx.requestFadeTransition) {
|
|
ctx.requestFadeTransition(targetState);
|
|
}
|
|
};
|
|
|
|
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();
|
|
const auto& asteroidRef = game->asteroidCells();
|
|
for (int idx = 0; idx < Game::COLS * Game::ROWS; ++idx) {
|
|
if (boardRef[idx] != 0 || asteroidRef[idx].has_value()) {
|
|
challengeClearFxOrder.push_back(idx);
|
|
}
|
|
}
|
|
if (challengeClearFxOrder.empty()) {
|
|
challengeClearFxOrder.reserve(Game::COLS * Game::ROWS);
|
|
for (int idx = 0; idx < Game::COLS * Game::ROWS; ++idx) {
|
|
challengeClearFxOrder.push_back(idx);
|
|
}
|
|
}
|
|
// Seed FX RNG deterministically from the game's challenge seed so animations
|
|
// are reproducible per-run and per-level. Fall back to a random seed if game absent.
|
|
if (game) {
|
|
challengeClearFxRng.seed(game->getChallengeSeedBase() + static_cast<uint32_t>(nextLevel));
|
|
} else {
|
|
challengeClearFxRng.seed(std::random_device{}());
|
|
}
|
|
std::shuffle(challengeClearFxOrder.begin(), challengeClearFxOrder.end(), challengeClearFxRng);
|
|
|
|
challengeClearFxElapsedMs = 0.0;
|
|
challengeClearFxDurationMs = std::clamp(800.0 + static_cast<double>(challengeClearFxOrder.size()) * 8.0, 900.0, 2600.0);
|
|
challengeClearFxNextLevel = nextLevel;
|
|
challengeClearFxActive = true;
|
|
gameplayCountdownActive = false;
|
|
gameplayCountdownElapsed = 0.0;
|
|
gameplayCountdownIndex = 0;
|
|
menuPlayCountdownArmed = false;
|
|
if (game) {
|
|
game->setPaused(true);
|
|
}
|
|
SoundEffectManager::instance().playSound("challenge_clear", 0.8f);
|
|
};
|
|
|
|
while (running)
|
|
{
|
|
if (!ctx.scores && scoresLoadComplete.load(std::memory_order_acquire)) {
|
|
ensureScoresLoaded();
|
|
}
|
|
|
|
int winW = 0, winH = 0;
|
|
SDL_GetWindowSize(window, &winW, &winH);
|
|
|
|
logicalScale = std::min(winW / (float)LOGICAL_W, winH / (float)LOGICAL_H);
|
|
if (logicalScale <= 0)
|
|
logicalScale = 1.f;
|
|
|
|
logicalVP.w = winW;
|
|
logicalVP.h = winH;
|
|
logicalVP.x = 0;
|
|
logicalVP.y = 0;
|
|
|
|
SDL_Event e;
|
|
while (SDL_PollEvent(&e))
|
|
{
|
|
if (e.type == SDL_EVENT_QUIT || e.type == SDL_EVENT_WINDOW_CLOSE_REQUESTED)
|
|
running = false;
|
|
else {
|
|
const bool isUserInputEvent =
|
|
e.type == SDL_EVENT_KEY_DOWN ||
|
|
e.type == SDL_EVENT_KEY_UP ||
|
|
e.type == SDL_EVENT_TEXT_INPUT ||
|
|
e.type == SDL_EVENT_MOUSE_BUTTON_DOWN ||
|
|
e.type == SDL_EVENT_MOUSE_BUTTON_UP ||
|
|
e.type == SDL_EVENT_MOUSE_MOTION;
|
|
|
|
if (!(showHelpOverlay && isUserInputEvent)) {
|
|
stateMgr->handleEvent(e);
|
|
state = stateMgr->getState();
|
|
}
|
|
|
|
if (e.type == SDL_EVENT_KEY_DOWN && !e.key.repeat) {
|
|
if (e.key.scancode == SDL_SCANCODE_M)
|
|
{
|
|
Audio::instance().toggleMute();
|
|
musicEnabled = !musicEnabled;
|
|
Settings::instance().setMusicEnabled(musicEnabled);
|
|
}
|
|
if (e.key.scancode == SDL_SCANCODE_N)
|
|
{
|
|
Audio::instance().skipToNextTrack();
|
|
if (!musicStarted && Audio::instance().getLoadedTrackCount() > 0) {
|
|
musicStarted = true;
|
|
musicEnabled = true;
|
|
Settings::instance().setMusicEnabled(true);
|
|
}
|
|
}
|
|
// K: Toggle sound effects (S is reserved for co-op movement)
|
|
if (e.key.scancode == SDL_SCANCODE_K)
|
|
{
|
|
SoundEffectManager::instance().setEnabled(!SoundEffectManager::instance().isEnabled());
|
|
Settings::instance().setSoundEnabled(SoundEffectManager::instance().isEnabled());
|
|
}
|
|
const bool helpToggleKey =
|
|
(e.key.scancode == SDL_SCANCODE_F1 && state != AppState::Loading && state != AppState::Menu);
|
|
if (helpToggleKey)
|
|
{
|
|
showHelpOverlay = !showHelpOverlay;
|
|
if (state == AppState::Playing) {
|
|
if (showHelpOverlay) {
|
|
if (!game->isPaused()) {
|
|
game->setPaused(true);
|
|
helpOverlayPausedGame = true;
|
|
} else {
|
|
helpOverlayPausedGame = false;
|
|
}
|
|
} else if (helpOverlayPausedGame) {
|
|
game->setPaused(false);
|
|
helpOverlayPausedGame = false;
|
|
}
|
|
} else if (!showHelpOverlay) {
|
|
helpOverlayPausedGame = false;
|
|
}
|
|
}
|
|
if (e.key.scancode == SDL_SCANCODE_ESCAPE && showHelpOverlay) {
|
|
showHelpOverlay = false;
|
|
if (state == AppState::Playing && helpOverlayPausedGame) {
|
|
game->setPaused(false);
|
|
}
|
|
helpOverlayPausedGame = false;
|
|
}
|
|
if (e.key.key == SDLK_F11 || (e.key.key == SDLK_RETURN && (e.key.mod & SDL_KMOD_ALT)))
|
|
{
|
|
isFullscreen = !isFullscreen;
|
|
SDL_SetWindowFullscreen(window, isFullscreen ? SDL_WINDOW_FULLSCREEN : 0);
|
|
Settings::instance().setFullscreen(isFullscreen);
|
|
}
|
|
if (e.key.scancode == SDL_SCANCODE_F5)
|
|
{
|
|
warpAutoPilotEnabled = false;
|
|
warpFlightMode = SpaceWarpFlightMode::Forward;
|
|
spaceWarp.setFlightMode(warpFlightMode);
|
|
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Space warp mode: forward");
|
|
}
|
|
if (e.key.scancode == SDL_SCANCODE_F6)
|
|
{
|
|
warpAutoPilotEnabled = false;
|
|
warpFlightMode = SpaceWarpFlightMode::BankLeft;
|
|
spaceWarp.setFlightMode(warpFlightMode);
|
|
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Space warp mode: bank left");
|
|
}
|
|
if (e.key.scancode == SDL_SCANCODE_F7)
|
|
{
|
|
warpAutoPilotEnabled = false;
|
|
warpFlightMode = SpaceWarpFlightMode::BankRight;
|
|
spaceWarp.setFlightMode(warpFlightMode);
|
|
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Space warp mode: bank right");
|
|
}
|
|
if (e.key.scancode == SDL_SCANCODE_F8)
|
|
{
|
|
warpAutoPilotEnabled = false;
|
|
warpFlightMode = SpaceWarpFlightMode::Reverse;
|
|
spaceWarp.setFlightMode(warpFlightMode);
|
|
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Space warp mode: reverse");
|
|
}
|
|
if (e.key.scancode == SDL_SCANCODE_F9)
|
|
{
|
|
warpAutoPilotEnabled = true;
|
|
spaceWarp.setAutoPilotEnabled(true);
|
|
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Space warp autopilot engaged");
|
|
}
|
|
}
|
|
|
|
if (!showHelpOverlay && state == AppState::GameOver && isNewHighScore && e.type == SDL_EVENT_TEXT_INPUT) {
|
|
if (playerName.length() < 12) {
|
|
playerName += e.text.text;
|
|
}
|
|
}
|
|
|
|
if (!showHelpOverlay && state == AppState::GameOver && e.type == SDL_EVENT_KEY_DOWN && !e.key.repeat) {
|
|
if (isNewHighScore) {
|
|
if (e.key.scancode == SDL_SCANCODE_BACKSPACE && !playerName.empty()) {
|
|
playerName.pop_back();
|
|
} else if (e.key.scancode == SDL_SCANCODE_RETURN || e.key.scancode == SDL_SCANCODE_KP_ENTER) {
|
|
if (playerName.empty()) playerName = "PLAYER";
|
|
ensureScoresLoaded();
|
|
scores.submit(game->score(), game->lines(), game->level(), game->elapsed(), playerName);
|
|
Settings::instance().setPlayerName(playerName);
|
|
isNewHighScore = false;
|
|
SDL_StopTextInput(window);
|
|
}
|
|
} else {
|
|
if (e.key.scancode == SDL_SCANCODE_RETURN || e.key.scancode == SDL_SCANCODE_KP_ENTER || e.key.scancode == SDL_SCANCODE_SPACE) {
|
|
if (game->getMode() == GameMode::Challenge) {
|
|
game->startChallengeRun(1);
|
|
} else if (game->getMode() == GameMode::Cooperate) {
|
|
game->setMode(GameMode::Cooperate);
|
|
game->reset(startLevelSelection);
|
|
} else {
|
|
game->setMode(GameMode::Endless);
|
|
game->reset(startLevelSelection);
|
|
}
|
|
state = AppState::Playing;
|
|
stateMgr->setState(state);
|
|
} else if (e.key.scancode == SDL_SCANCODE_ESCAPE) {
|
|
state = AppState::Menu;
|
|
stateMgr->setState(state);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!showHelpOverlay && e.type == SDL_EVENT_MOUSE_BUTTON_DOWN)
|
|
{
|
|
float mx = (float)e.button.x, my = (float)e.button.y;
|
|
if (mx >= logicalVP.x && my >= logicalVP.y && mx <= logicalVP.x + logicalVP.w && my <= logicalVP.y + logicalVP.h)
|
|
{
|
|
float lx = (mx - logicalVP.x) / logicalScale, ly = (my - logicalVP.y) / logicalScale;
|
|
if (state == AppState::Menu)
|
|
{
|
|
if (showSettingsPopup) {
|
|
showSettingsPopup = false;
|
|
} else {
|
|
ui::MenuLayoutParams params{ LOGICAL_W, LOGICAL_H, winW, winH, logicalScale };
|
|
|
|
auto menuInput = ui::handleBottomMenuInput(params, e, lx, ly, hoveredButton, true);
|
|
hoveredButton = menuInput.hoveredIndex;
|
|
|
|
if (menuInput.activated) {
|
|
switch (*menuInput.activated) {
|
|
case ui::BottomMenuItem::Play:
|
|
if (game) game->setMode(GameMode::Endless);
|
|
startMenuPlayTransition();
|
|
break;
|
|
case ui::BottomMenuItem::Cooperate:
|
|
if (game) {
|
|
game->setMode(GameMode::Cooperate);
|
|
game->reset(startLevelSelection);
|
|
}
|
|
startMenuPlayTransition();
|
|
break;
|
|
case ui::BottomMenuItem::Challenge:
|
|
if (game) {
|
|
game->setMode(GameMode::Challenge);
|
|
// Suppress the initial level-up jingle when starting Challenge from menu
|
|
skipNextLevelUpJingle = true;
|
|
game->startChallengeRun(1);
|
|
}
|
|
startMenuPlayTransition();
|
|
break;
|
|
case ui::BottomMenuItem::Level:
|
|
requestStateFade(AppState::LevelSelector);
|
|
break;
|
|
case ui::BottomMenuItem::Options:
|
|
requestStateFade(AppState::Options);
|
|
break;
|
|
case ui::BottomMenuItem::Help:
|
|
if (menuState) menuState->showHelpPanel(true);
|
|
break;
|
|
case ui::BottomMenuItem::About:
|
|
if (menuState) menuState->showAboutPanel(true);
|
|
break;
|
|
case ui::BottomMenuItem::Exit:
|
|
showExitConfirmPopup = true;
|
|
exitPopupSelectedButton = 1;
|
|
break;
|
|
}
|
|
}
|
|
|
|
SDL_FRect settingsBtn{SETTINGS_BTN_X, SETTINGS_BTN_Y, SETTINGS_BTN_W, SETTINGS_BTN_H};
|
|
if (lx >= settingsBtn.x && lx <= settingsBtn.x + settingsBtn.w && ly >= settingsBtn.y && ly <= settingsBtn.y + settingsBtn.h)
|
|
{
|
|
showSettingsPopup = true;
|
|
}
|
|
}
|
|
}
|
|
else if (state == AppState::LevelSelect)
|
|
startLevelSelection = (startLevelSelection + 1) % 20;
|
|
else if (state == AppState::GameOver) {
|
|
state = AppState::Menu;
|
|
stateMgr->setState(state);
|
|
}
|
|
else if (state == AppState::Playing && showExitConfirmPopup) {
|
|
float lx2 = (mx - logicalVP.x) / logicalScale;
|
|
float ly2 = (my - logicalVP.y) / logicalScale;
|
|
float contentW = LOGICAL_W * logicalScale;
|
|
float contentH = LOGICAL_H * logicalScale;
|
|
float contentOffsetX = (winW - contentW) * 0.5f / logicalScale;
|
|
float contentOffsetY = (winH - contentH) * 0.5f / logicalScale;
|
|
float localX = lx2 - contentOffsetX;
|
|
float localY = ly2 - contentOffsetY;
|
|
|
|
float popupW = 400, popupH = 200;
|
|
float popupX = (LOGICAL_W - popupW) / 2.0f;
|
|
float popupY = (LOGICAL_H - popupH) / 2.0f;
|
|
float btnW = 120.0f, btnH = 40.0f;
|
|
float yesX = popupX + popupW * 0.25f - btnW / 2.0f;
|
|
float noX = popupX + popupW * 0.75f - btnW / 2.0f;
|
|
float btnY = popupY + popupH - btnH - 20.0f;
|
|
|
|
if (localX >= popupX && localX <= popupX + popupW && localY >= popupY && localY <= popupY + popupH) {
|
|
if (localX >= yesX && localX <= yesX + btnW && localY >= btnY && localY <= btnY + btnH) {
|
|
showExitConfirmPopup = false;
|
|
game->reset(startLevelSelection);
|
|
state = AppState::Menu;
|
|
stateMgr->setState(state);
|
|
} else if (localX >= noX && localX <= noX + btnW && localY >= btnY && localY <= btnY + btnH) {
|
|
showExitConfirmPopup = false;
|
|
game->setPaused(false);
|
|
}
|
|
} else {
|
|
showExitConfirmPopup = false;
|
|
game->setPaused(false);
|
|
}
|
|
}
|
|
else if (state == AppState::Menu && showExitConfirmPopup) {
|
|
float contentW = LOGICAL_W * logicalScale;
|
|
float contentH = LOGICAL_H * logicalScale;
|
|
float contentOffsetX = (winW - contentW) * 0.5f / logicalScale;
|
|
float contentOffsetY = (winH - contentH) * 0.5f / logicalScale;
|
|
float popupW = 420.0f;
|
|
float popupH = 230.0f;
|
|
float popupX = (LOGICAL_W - popupW) * 0.5f + contentOffsetX;
|
|
float popupY = (LOGICAL_H - popupH) * 0.5f + contentOffsetY;
|
|
float btnW = 140.0f;
|
|
float btnH = 50.0f;
|
|
float yesX = popupX + popupW * 0.3f - btnW / 2.0f;
|
|
float noX = popupX + popupW * 0.7f - btnW / 2.0f;
|
|
float btnY = popupY + popupH - btnH - 30.0f;
|
|
bool insidePopup = lx >= popupX && lx <= popupX + popupW && ly >= popupY && ly <= popupY + popupH;
|
|
if (insidePopup) {
|
|
if (lx >= yesX && lx <= yesX + btnW && ly >= btnY && ly <= btnY + btnH) {
|
|
showExitConfirmPopup = false;
|
|
running = false;
|
|
} else if (lx >= noX && lx <= noX + btnW && ly >= btnY && ly <= btnY + btnH) {
|
|
showExitConfirmPopup = false;
|
|
}
|
|
} else {
|
|
showExitConfirmPopup = false;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
else if (!showHelpOverlay && e.type == SDL_EVENT_MOUSE_MOTION)
|
|
{
|
|
float mx = (float)e.motion.x, my = (float)e.motion.y;
|
|
if (mx >= logicalVP.x && my >= logicalVP.y && mx <= logicalVP.x + logicalVP.w && my <= logicalVP.y + logicalVP.h)
|
|
{
|
|
float lx = (mx - logicalVP.x) / logicalScale, ly = (my - logicalVP.y) / logicalScale;
|
|
if (state == AppState::Menu && !showSettingsPopup)
|
|
{
|
|
ui::MenuLayoutParams params{ LOGICAL_W, LOGICAL_H, winW, winH, logicalScale };
|
|
auto menuInput = ui::handleBottomMenuInput(params, e, lx, ly, hoveredButton, true);
|
|
hoveredButton = menuInput.hoveredIndex;
|
|
}
|
|
}
|
|
}
|
|
else if (e.type == SDL_EVENT_KEY_DOWN && !e.key.repeat) {
|
|
if (gameplayCountdownActive && gameplayCountdownSource == CountdownSource::ChallengeLevel && challengeCountdownWaitingForSpace) {
|
|
if (e.key.scancode == SDL_SCANCODE_SPACE) {
|
|
challengeCountdownWaitingForSpace = false;
|
|
gameplayCountdownElapsed = 0.0;
|
|
gameplayCountdownIndex = 0;
|
|
} else if (e.key.scancode == SDL_SCANCODE_ESCAPE) {
|
|
// Show quit popup, keep game paused, cancel countdown
|
|
if (!showExitConfirmPopup) {
|
|
showExitConfirmPopup = true;
|
|
exitPopupSelectedButton = 1; // default to NO
|
|
}
|
|
gameplayCountdownActive = false;
|
|
menuPlayCountdownArmed = false;
|
|
gameplayCountdownElapsed = 0.0;
|
|
gameplayCountdownIndex = 0;
|
|
countdownAdvancesChallenge = false;
|
|
challengeCountdownWaitingForSpace = false;
|
|
if (game) game->setPaused(true);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Uint64 now = SDL_GetPerformanceCounter();
|
|
double frameMs = double(now - lastMs) * 1000.0 / double(SDL_GetPerformanceFrequency());
|
|
lastMs = now;
|
|
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; during countdown wait we keep it fully visible
|
|
if (state == AppState::Playing && game && game->getMode() == GameMode::Challenge && !challengeStoryText.empty()) {
|
|
if (gameplayCountdownActive && gameplayCountdownSource == CountdownSource::ChallengeLevel && challengeCountdownWaitingForSpace) {
|
|
// Locked-visible while waiting
|
|
challengeStoryAlpha = 1.0f;
|
|
} else {
|
|
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<float>(std::clamp(a, 0.0, 1.0));
|
|
}
|
|
}
|
|
} else {
|
|
clearChallengeStory();
|
|
}
|
|
|
|
if (challengeClearFxActive) {
|
|
challengeClearFxElapsedMs += frameMs;
|
|
if (challengeClearFxElapsedMs >= challengeClearFxDurationMs) {
|
|
challengeClearFxElapsedMs = challengeClearFxDurationMs;
|
|
challengeClearFxActive = false;
|
|
if (challengeClearFxNextLevel > 0) {
|
|
// Advance to the next challenge level immediately so the countdown shows the new board/asteroids
|
|
if (game) {
|
|
game->beginNextChallengeLevel();
|
|
game->setPaused(true);
|
|
}
|
|
gameplayCountdownSource = CountdownSource::ChallengeLevel;
|
|
countdownLevel = challengeClearFxNextLevel;
|
|
countdownGoalAsteroids = challengeClearFxNextLevel;
|
|
captureChallengeStory(countdownLevel);
|
|
countdownAdvancesChallenge = false; // already advanced
|
|
gameplayCountdownActive = true;
|
|
challengeCountdownWaitingForSpace = true;
|
|
menuPlayCountdownArmed = false;
|
|
gameplayCountdownElapsed = 0.0;
|
|
gameplayCountdownIndex = 0;
|
|
SoundEffectManager::instance().playSound("new_level", 1.0f);
|
|
skipNextLevelUpJingle = true;
|
|
}
|
|
challengeClearFxNextLevel = 0;
|
|
}
|
|
}
|
|
|
|
const bool *ks = SDL_GetKeyboardState(nullptr);
|
|
bool left = state == AppState::Playing && ks[SDL_SCANCODE_LEFT];
|
|
bool right = state == AppState::Playing && ks[SDL_SCANCODE_RIGHT];
|
|
bool down = state == AppState::Playing && ks[SDL_SCANCODE_DOWN];
|
|
|
|
if (state == AppState::Playing)
|
|
game->setSoftDropping(down && !game->isPaused());
|
|
else
|
|
game->setSoftDropping(false);
|
|
|
|
int moveDir = 0;
|
|
if (left && !right)
|
|
moveDir = -1;
|
|
else if (right && !left)
|
|
moveDir = +1;
|
|
|
|
if (moveDir != 0 && !game->isPaused())
|
|
{
|
|
if ((moveDir == -1 && leftHeld == false) || (moveDir == +1 && rightHeld == false))
|
|
{
|
|
game->move(moveDir);
|
|
moveTimerMs = DAS;
|
|
}
|
|
else
|
|
{
|
|
moveTimerMs -= frameMs;
|
|
if (moveTimerMs <= 0)
|
|
{
|
|
game->move(moveDir);
|
|
moveTimerMs += ARR;
|
|
}
|
|
}
|
|
}
|
|
else
|
|
moveTimerMs = 0;
|
|
leftHeld = left;
|
|
rightHeld = right;
|
|
if (down && !game->isPaused())
|
|
game->softDropBoost(frameMs);
|
|
|
|
if (musicLoadingStarted && !musicLoaded) {
|
|
currentTrackLoading = Audio::instance().getLoadedTrackCount();
|
|
if (Audio::instance().isLoadingComplete() || (totalTracks > 0 && currentTrackLoading >= totalTracks)) {
|
|
Audio::instance().shuffle();
|
|
musicLoaded = true;
|
|
}
|
|
}
|
|
|
|
if (state == AppState::Playing)
|
|
{
|
|
const bool coopActive = game && game->getMode() == GameMode::Cooperate && coopGame;
|
|
|
|
if (coopActive) {
|
|
// Coop DAS/ARR handling (per-side)
|
|
const bool* ks = SDL_GetKeyboardState(nullptr);
|
|
|
|
auto handleSide = [&](CoopGame::PlayerSide side,
|
|
bool leftHeldPrev,
|
|
bool rightHeldPrev,
|
|
double& timer,
|
|
SDL_Scancode leftKey,
|
|
SDL_Scancode rightKey,
|
|
SDL_Scancode downKey) {
|
|
bool left = ks[leftKey];
|
|
bool right = ks[rightKey];
|
|
bool down = ks[downKey];
|
|
|
|
coopGame->setSoftDropping(side, down);
|
|
|
|
int moveDir = 0;
|
|
if (left && !right) moveDir = -1;
|
|
else if (right && !left) moveDir = +1;
|
|
|
|
if (moveDir != 0) {
|
|
if ((moveDir == -1 && !leftHeldPrev) || (moveDir == +1 && !rightHeldPrev)) {
|
|
coopGame->move(side, moveDir);
|
|
timer = DAS;
|
|
} else {
|
|
timer -= frameMs;
|
|
if (timer <= 0) {
|
|
coopGame->move(side, moveDir);
|
|
timer += ARR;
|
|
}
|
|
}
|
|
} else {
|
|
timer = 0.0;
|
|
}
|
|
};
|
|
|
|
if (game->isPaused()) {
|
|
// While paused, suppress all continuous input changes so pieces don't drift.
|
|
coopGame->setSoftDropping(CoopGame::PlayerSide::Left, false);
|
|
coopGame->setSoftDropping(CoopGame::PlayerSide::Right, false);
|
|
p1MoveTimerMs = 0.0;
|
|
p2MoveTimerMs = 0.0;
|
|
p1LeftHeld = false;
|
|
p1RightHeld = false;
|
|
p2LeftHeld = false;
|
|
p2RightHeld = false;
|
|
} else {
|
|
handleSide(CoopGame::PlayerSide::Left, p1LeftHeld, p1RightHeld, p1MoveTimerMs, SDL_SCANCODE_A, SDL_SCANCODE_D, SDL_SCANCODE_S);
|
|
handleSide(CoopGame::PlayerSide::Right, p2LeftHeld, p2RightHeld, p2MoveTimerMs, SDL_SCANCODE_LEFT, SDL_SCANCODE_RIGHT, SDL_SCANCODE_DOWN);
|
|
|
|
p1LeftHeld = ks[SDL_SCANCODE_A];
|
|
p1RightHeld = ks[SDL_SCANCODE_D];
|
|
p2LeftHeld = ks[SDL_SCANCODE_LEFT];
|
|
p2RightHeld = ks[SDL_SCANCODE_RIGHT];
|
|
|
|
coopGame->tickGravity(frameMs);
|
|
coopGame->updateVisualEffects(frameMs);
|
|
}
|
|
|
|
if (coopGame->isGameOver()) {
|
|
state = AppState::GameOver;
|
|
stateMgr->setState(state);
|
|
}
|
|
|
|
} else {
|
|
if (!game->isPaused()) {
|
|
game->tickGravity(frameMs);
|
|
game->updateElapsedTime();
|
|
|
|
if (lineEffect.isActive()) {
|
|
if (lineEffect.update(frameMs / 1000.0f)) {
|
|
game->clearCompletedLines();
|
|
}
|
|
}
|
|
}
|
|
if (game->isGameOver())
|
|
{
|
|
if (game->score() > 0) {
|
|
isNewHighScore = true;
|
|
playerName.clear();
|
|
SDL_StartTextInput(window);
|
|
} else {
|
|
isNewHighScore = false;
|
|
ensureScoresLoaded();
|
|
scores.submit(game->score(), game->lines(), game->level(), game->elapsed());
|
|
}
|
|
state = AppState::GameOver;
|
|
stateMgr->setState(state);
|
|
}
|
|
}
|
|
}
|
|
else if (state == AppState::Loading)
|
|
{
|
|
static int queuedTextureCount = 0;
|
|
if (loadingStarted.load() && !loadingComplete.load()) {
|
|
static bool queuedTextures = false;
|
|
static std::vector<std::string> queuedPaths;
|
|
if (!queuedTextures) {
|
|
queuedTextures = true;
|
|
constexpr int baseTasks = 25;
|
|
totalLoadingTasks.store(baseTasks);
|
|
loadedTasks.store(0);
|
|
{
|
|
std::lock_guard<std::mutex> lk(assetLoadErrorsMutex);
|
|
assetLoadErrors.clear();
|
|
}
|
|
{
|
|
std::lock_guard<std::mutex> lk(currentLoadingMutex);
|
|
currentLoadingFile.clear();
|
|
}
|
|
|
|
Audio::instance().init();
|
|
totalTracks = 0;
|
|
for (int i = 1; i <= 100; ++i) {
|
|
char base[128];
|
|
std::snprintf(base, sizeof(base), "assets/music/music%03d", i);
|
|
std::string path = AssetPath::resolveWithExtensions(base, { ".mp3" });
|
|
if (path.empty()) break;
|
|
Audio::instance().addTrackAsync(path);
|
|
totalTracks++;
|
|
}
|
|
totalLoadingTasks.store(baseTasks + totalTracks);
|
|
if (totalTracks > 0) {
|
|
Audio::instance().startBackgroundLoading();
|
|
musicLoadingStarted = true;
|
|
} else {
|
|
musicLoaded = true;
|
|
}
|
|
|
|
pixelFont.init(AssetPath::resolveWithBase(Assets::FONT_ORBITRON), 22);
|
|
loadedTasks.fetch_add(1);
|
|
font.init(AssetPath::resolveWithBase(Assets::FONT_EXO2), 20);
|
|
loadedTasks.fetch_add(1);
|
|
|
|
queuedPaths = {
|
|
Assets::LOGO,
|
|
Assets::LOGO,
|
|
Assets::MAIN_SCREEN,
|
|
Assets::BLOCKS_SPRITE,
|
|
Assets::PANEL_SCORE,
|
|
Assets::PANEL_STATS,
|
|
Assets::NEXT_PANEL,
|
|
Assets::HOLD_PANEL,
|
|
Assets::ASTEROID_SPRITE
|
|
};
|
|
for (auto &p : queuedPaths) {
|
|
loadingManager->queueTexture(p);
|
|
}
|
|
queuedTextureCount = static_cast<int>(queuedPaths.size());
|
|
|
|
SoundEffectManager::instance().init();
|
|
loadedTasks.fetch_add(1);
|
|
|
|
const std::vector<std::string> audioIds = {"clear_line","nice_combo","you_fire","well_played","keep_that_ryhtm","great_move","smooth_clear","impressive","triple_strike","amazing","you_re_unstoppable","boom_tetris","wonderful","lets_go","hard_drop","new_level","asteroid_destroy","challenge_clear"};
|
|
for (const auto &id : audioIds) {
|
|
std::string basePath = "assets/music/" + (id == "hard_drop"
|
|
? "hard_drop_001"
|
|
: (id == "challenge_clear"
|
|
? "GONG0"
|
|
: (id == "asteroid_destroy"
|
|
? "asteroid-destroy"
|
|
: id)));
|
|
{
|
|
std::lock_guard<std::mutex> lk(currentLoadingMutex);
|
|
currentLoadingFile = basePath;
|
|
}
|
|
std::string resolved = AssetPath::resolveWithExtensions(basePath, { ".wav", ".mp3" });
|
|
if (!resolved.empty()) {
|
|
SoundEffectManager::instance().loadSound(id, resolved);
|
|
}
|
|
loadedTasks.fetch_add(1);
|
|
{
|
|
std::lock_guard<std::mutex> lk(currentLoadingMutex);
|
|
currentLoadingFile.clear();
|
|
}
|
|
}
|
|
}
|
|
|
|
bool texturesDone = loadingManager->update();
|
|
if (texturesDone) {
|
|
logoTex = assetLoader.getTexture(Assets::LOGO);
|
|
logoSmallTex = assetLoader.getTexture(Assets::LOGO);
|
|
mainScreenTex = assetLoader.getTexture(Assets::MAIN_SCREEN);
|
|
blocksTex = assetLoader.getTexture(Assets::BLOCKS_SPRITE);
|
|
asteroidsTex = assetLoader.getTexture(Assets::ASTEROID_SPRITE);
|
|
scorePanelTex = assetLoader.getTexture(Assets::PANEL_SCORE);
|
|
statisticsPanelTex = assetLoader.getTexture(Assets::PANEL_STATS);
|
|
nextPanelTex = assetLoader.getTexture(Assets::NEXT_PANEL);
|
|
holdPanelTex = assetLoader.getTexture(Assets::HOLD_PANEL);
|
|
|
|
auto ensureTextureSize = [&](SDL_Texture* tex, int& outW, int& outH) {
|
|
if (!tex) return;
|
|
if (outW > 0 && outH > 0) return;
|
|
float w = 0.0f, h = 0.0f;
|
|
if (SDL_GetTextureSize(tex, &w, &h)) {
|
|
outW = static_cast<int>(std::lround(w));
|
|
outH = static_cast<int>(std::lround(h));
|
|
}
|
|
};
|
|
|
|
ensureTextureSize(logoSmallTex, logoSmallW, logoSmallH);
|
|
ensureTextureSize(mainScreenTex, mainScreenW, mainScreenH);
|
|
|
|
auto legacyLoad = [&](const std::string& p, SDL_Texture*& outTex, int* outW = nullptr, int* outH = nullptr) {
|
|
if (!outTex) {
|
|
SDL_Texture* loaded = textureLoader->loadFromImage(renderer, p, outW, outH);
|
|
if (loaded) {
|
|
outTex = loaded;
|
|
assetLoader.adoptTexture(p, loaded);
|
|
}
|
|
}
|
|
};
|
|
|
|
legacyLoad(Assets::LOGO, logoTex);
|
|
legacyLoad(Assets::LOGO, logoSmallTex, &logoSmallW, &logoSmallH);
|
|
legacyLoad(Assets::MAIN_SCREEN, mainScreenTex, &mainScreenW, &mainScreenH);
|
|
legacyLoad(Assets::BLOCKS_SPRITE, blocksTex);
|
|
legacyLoad(Assets::ASTEROID_SPRITE, asteroidsTex);
|
|
legacyLoad(Assets::PANEL_SCORE, scorePanelTex);
|
|
legacyLoad(Assets::PANEL_STATS, statisticsPanelTex);
|
|
legacyLoad(Assets::NEXT_PANEL, nextPanelTex);
|
|
legacyLoad(Assets::HOLD_PANEL, holdPanelTex);
|
|
|
|
if (!blocksTex) {
|
|
blocksTex = SDL_CreateTexture(renderer, SDL_PIXELFORMAT_RGBA8888, SDL_TEXTUREACCESS_TARGET, 630, 90);
|
|
SDL_SetRenderTarget(renderer, blocksTex);
|
|
SDL_SetRenderDrawColor(renderer, 0, 0, 0, 0);
|
|
SDL_RenderClear(renderer);
|
|
for (int i = 0; i < PIECE_COUNT; ++i) {
|
|
SDL_Color c = COLORS[i + 1];
|
|
SDL_SetRenderDrawColor(renderer, c.r, c.g, c.b, c.a);
|
|
SDL_FRect rect{(float)(i * 90), 0, 90, 90};
|
|
SDL_RenderFillRect(renderer, &rect);
|
|
}
|
|
SDL_SetRenderTarget(renderer, nullptr);
|
|
|
|
// Ensure the generated fallback texture is cleaned up with other assets.
|
|
assetLoader.adoptTexture(Assets::BLOCKS_SPRITE, blocksTex);
|
|
}
|
|
|
|
if (musicLoaded) {
|
|
loadingComplete.store(true);
|
|
}
|
|
}
|
|
}
|
|
|
|
const int totalTasks = totalLoadingTasks.load(std::memory_order_acquire);
|
|
const int musicDone = std::min(totalTracks, currentTrackLoading);
|
|
int doneTasks = loadedTasks.load(std::memory_order_acquire) + musicDone;
|
|
if (queuedTextureCount > 0) {
|
|
float texProg = loadingManager->getProgress();
|
|
int texDone = static_cast<int>(std::floor(texProg * queuedTextureCount + 0.5f));
|
|
if (texDone > queuedTextureCount) texDone = queuedTextureCount;
|
|
doneTasks += texDone;
|
|
}
|
|
if (doneTasks > totalTasks) doneTasks = totalTasks;
|
|
if (totalTasks > 0) {
|
|
loadingProgress = std::min(1.0, double(doneTasks) / double(totalTasks));
|
|
if (loadingProgress >= 1.0 && musicLoaded) {
|
|
state = AppState::Menu;
|
|
stateMgr->setState(state);
|
|
}
|
|
} else {
|
|
double assetProgress = 0.2;
|
|
double musicProgress = 0.0;
|
|
if (totalTracks > 0) {
|
|
musicProgress = musicLoaded ? 0.7 : std::min(0.7, (double)currentTrackLoading / totalTracks * 0.7);
|
|
} else {
|
|
if (Audio::instance().isLoadingComplete()) {
|
|
musicProgress = 0.7;
|
|
} else if (Audio::instance().getLoadedTrackCount() > 0) {
|
|
musicProgress = 0.35;
|
|
} else {
|
|
Uint32 elapsedMs = SDL_GetTicks() - static_cast<Uint32>(loadStart);
|
|
if (elapsedMs > 1500) {
|
|
musicProgress = 0.7;
|
|
musicLoaded = true;
|
|
} else {
|
|
musicProgress = 0.0;
|
|
}
|
|
}
|
|
}
|
|
double timeProgress = std::min(0.1, (now - loadStart) / 500.0);
|
|
loadingProgress = std::min(1.0, assetProgress + musicProgress + timeProgress);
|
|
if (loadingProgress > 0.99) loadingProgress = 1.0;
|
|
if (!musicLoaded && timeProgress >= 0.1) loadingProgress = 1.0;
|
|
if (loadingProgress >= 1.0 && musicLoaded) {
|
|
state = AppState::Menu;
|
|
stateMgr->setState(state);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (state == AppState::Menu || state == AppState::Playing)
|
|
{
|
|
if (!musicStarted && musicLoaded)
|
|
{
|
|
static bool menuTrackLoaded = false;
|
|
if (!menuTrackLoaded) {
|
|
if (menuTrackLoader.joinable()) {
|
|
menuTrackLoader.join();
|
|
}
|
|
menuTrackLoader = std::jthread([]() {
|
|
std::string menuTrack = AssetPath::resolveWithExtensions("assets/music/Every Block You Take", { ".mp3" });
|
|
if (!menuTrack.empty()) {
|
|
Audio::instance().setMenuTrack(menuTrack);
|
|
} else {
|
|
SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "Menu track not found (Every Block You Take)");
|
|
}
|
|
});
|
|
menuTrackLoaded = true;
|
|
}
|
|
|
|
if (state == AppState::Menu) {
|
|
Audio::instance().playMenuMusic();
|
|
} else {
|
|
Audio::instance().playGameMusic();
|
|
}
|
|
musicStarted = true;
|
|
}
|
|
}
|
|
|
|
static AppState previousState = AppState::Loading;
|
|
if (state != previousState && musicStarted) {
|
|
if (state == AppState::Menu && previousState == AppState::Playing) {
|
|
Audio::instance().playMenuMusic();
|
|
} else if (state == AppState::Playing && previousState == AppState::Menu) {
|
|
Audio::instance().playGameMusic();
|
|
}
|
|
}
|
|
previousState = state;
|
|
|
|
if (state == AppState::Loading) {
|
|
starfield3D.update(float(frameMs / 1000.0f));
|
|
starfield3D.resize(winW, winH);
|
|
} else {
|
|
starfield.update(float(frameMs / 1000.0f), logicalVP.x * 2 + logicalVP.w, logicalVP.y * 2 + logicalVP.h);
|
|
}
|
|
|
|
if (state == AppState::Menu) {
|
|
spaceWarp.resize(winW, winH);
|
|
spaceWarp.update(float(frameMs / 1000.0f));
|
|
}
|
|
|
|
levelBackgrounds.update(float(frameMs));
|
|
|
|
if (state == AppState::Menu) {
|
|
logoAnimCounter += frameMs * 0.0008;
|
|
}
|
|
|
|
switch (stateMgr->getState()) {
|
|
case AppState::Loading:
|
|
loadingState->update(frameMs);
|
|
break;
|
|
case AppState::Menu:
|
|
menuState->update(frameMs);
|
|
break;
|
|
case AppState::Options:
|
|
optionsState->update(frameMs);
|
|
break;
|
|
case AppState::LevelSelector:
|
|
levelSelectorState->update(frameMs);
|
|
break;
|
|
case AppState::Playing:
|
|
playingState->update(frameMs);
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
|
|
if (state == AppState::Playing && game && game->getMode() == GameMode::Challenge && !gameplayCountdownActive && !challengeClearFxActive) {
|
|
int queuedLevel = game->consumeQueuedChallengeLevel();
|
|
if (queuedLevel > 0) {
|
|
startChallengeClearFx(queuedLevel);
|
|
}
|
|
}
|
|
|
|
ctx.logoTex = logoTex;
|
|
ctx.logoSmallTex = logoSmallTex;
|
|
ctx.logoSmallW = logoSmallW;
|
|
ctx.logoSmallH = logoSmallH;
|
|
ctx.backgroundTex = backgroundTex;
|
|
ctx.blocksTex = blocksTex;
|
|
ctx.asteroidsTex = asteroidsTex;
|
|
ctx.scorePanelTex = scorePanelTex;
|
|
ctx.statisticsPanelTex = statisticsPanelTex;
|
|
ctx.nextPanelTex = nextPanelTex;
|
|
ctx.holdPanelTex = holdPanelTex;
|
|
ctx.mainScreenTex = mainScreenTex;
|
|
ctx.mainScreenW = mainScreenW;
|
|
ctx.mainScreenH = mainScreenH;
|
|
|
|
if (menuFadePhase == MenuFadePhase::FadeOut) {
|
|
menuFadeClockMs += frameMs;
|
|
menuFadeAlpha = std::min(1.0f, float(menuFadeClockMs / MENU_PLAY_FADE_DURATION_MS));
|
|
if (menuFadeClockMs >= MENU_PLAY_FADE_DURATION_MS) {
|
|
if (state != menuFadeTarget) {
|
|
state = menuFadeTarget;
|
|
stateMgr->setState(state);
|
|
}
|
|
|
|
if (menuFadeTarget == AppState::Playing) {
|
|
gameplayCountdownSource = (game && game->getMode() == GameMode::Challenge)
|
|
? CountdownSource::ChallengeLevel
|
|
: CountdownSource::MenuStart;
|
|
countdownLevel = game ? game->challengeLevel() : 1;
|
|
countdownGoalAsteroids = countdownLevel;
|
|
if (gameplayCountdownSource == CountdownSource::ChallengeLevel) {
|
|
captureChallengeStory(countdownLevel);
|
|
challengeCountdownWaitingForSpace = true;
|
|
} else {
|
|
challengeStoryText.clear();
|
|
challengeStoryLevel = 0;
|
|
challengeCountdownWaitingForSpace = false;
|
|
}
|
|
countdownAdvancesChallenge = false;
|
|
menuPlayCountdownArmed = true;
|
|
gameplayCountdownActive = false;
|
|
gameplayCountdownIndex = 0;
|
|
gameplayCountdownElapsed = 0.0;
|
|
game->setPaused(true);
|
|
} else {
|
|
menuPlayCountdownArmed = false;
|
|
gameplayCountdownActive = false;
|
|
gameplayCountdownIndex = 0;
|
|
gameplayCountdownElapsed = 0.0;
|
|
challengeCountdownWaitingForSpace = false;
|
|
game->setPaused(false);
|
|
}
|
|
menuFadePhase = MenuFadePhase::FadeIn;
|
|
menuFadeClockMs = MENU_PLAY_FADE_DURATION_MS;
|
|
menuFadeAlpha = 1.0f;
|
|
}
|
|
} else if (menuFadePhase == MenuFadePhase::FadeIn) {
|
|
menuFadeClockMs -= frameMs;
|
|
menuFadeAlpha = std::max(0.0f, float(menuFadeClockMs / MENU_PLAY_FADE_DURATION_MS));
|
|
if (menuFadeClockMs <= 0.0) {
|
|
menuFadePhase = MenuFadePhase::None;
|
|
menuFadeClockMs = 0.0;
|
|
menuFadeAlpha = 0.0f;
|
|
}
|
|
}
|
|
|
|
if (menuFadePhase == MenuFadePhase::None && menuPlayCountdownArmed && !gameplayCountdownActive && state == AppState::Playing) {
|
|
gameplayCountdownSource = (game && game->getMode() == GameMode::Challenge)
|
|
? CountdownSource::ChallengeLevel
|
|
: CountdownSource::MenuStart;
|
|
countdownLevel = game ? game->challengeLevel() : 1;
|
|
countdownGoalAsteroids = countdownLevel;
|
|
if (gameplayCountdownSource == CountdownSource::ChallengeLevel) {
|
|
captureChallengeStory(countdownLevel);
|
|
challengeCountdownWaitingForSpace = true;
|
|
} else {
|
|
challengeStoryText.clear();
|
|
challengeStoryLevel = 0;
|
|
challengeCountdownWaitingForSpace = false;
|
|
}
|
|
countdownAdvancesChallenge = false;
|
|
gameplayCountdownActive = true;
|
|
menuPlayCountdownArmed = false;
|
|
gameplayCountdownElapsed = 0.0;
|
|
gameplayCountdownIndex = 0;
|
|
game->setPaused(true);
|
|
}
|
|
|
|
if (gameplayCountdownActive && state == AppState::Playing) {
|
|
if (!challengeCountdownWaitingForSpace || gameplayCountdownSource != CountdownSource::ChallengeLevel) {
|
|
gameplayCountdownElapsed += frameMs;
|
|
if (gameplayCountdownElapsed >= GAMEPLAY_COUNTDOWN_STEP_MS) {
|
|
gameplayCountdownElapsed -= GAMEPLAY_COUNTDOWN_STEP_MS;
|
|
++gameplayCountdownIndex;
|
|
if (gameplayCountdownIndex >= static_cast<int>(GAMEPLAY_COUNTDOWN_LABELS.size())) {
|
|
gameplayCountdownActive = false;
|
|
gameplayCountdownElapsed = 0.0;
|
|
gameplayCountdownIndex = 0;
|
|
if (gameplayCountdownSource == CountdownSource::ChallengeLevel && countdownAdvancesChallenge && game) {
|
|
game->beginNextChallengeLevel();
|
|
}
|
|
countdownAdvancesChallenge = false;
|
|
game->setPaused(false);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (state != AppState::Playing && gameplayCountdownActive) {
|
|
gameplayCountdownActive = false;
|
|
menuPlayCountdownArmed = false;
|
|
gameplayCountdownElapsed = 0.0;
|
|
gameplayCountdownIndex = 0;
|
|
countdownAdvancesChallenge = false;
|
|
challengeCountdownWaitingForSpace = false;
|
|
game->setPaused(false);
|
|
}
|
|
|
|
if (state != AppState::Playing && challengeClearFxActive) {
|
|
challengeClearFxActive = false;
|
|
challengeClearFxElapsedMs = 0.0;
|
|
challengeClearFxDurationMs = 0.0;
|
|
challengeClearFxNextLevel = 0;
|
|
challengeClearFxOrder.clear();
|
|
}
|
|
|
|
SDL_SetRenderViewport(renderer, nullptr);
|
|
SDL_SetRenderDrawColor(renderer, 0, 0, 0, 255);
|
|
SDL_RenderClear(renderer);
|
|
|
|
if (state == AppState::Playing) {
|
|
int bgLevel = std::clamp(game->level(), 0, 32);
|
|
levelBackgrounds.queueLevelBackground(renderer, bgLevel);
|
|
levelBackgrounds.render(renderer, winW, winH, static_cast<float>(gameplayBackgroundClockMs));
|
|
} else if (state == AppState::Loading) {
|
|
starfield3D.draw(renderer);
|
|
} else if (state == AppState::Menu) {
|
|
spaceWarp.draw(renderer, 1.0f);
|
|
} else if (state == AppState::LevelSelector || state == AppState::Options) {
|
|
// No background texture
|
|
} else {
|
|
starfield.draw(renderer);
|
|
}
|
|
|
|
SDL_SetRenderViewport(renderer, &logicalVP);
|
|
SDL_SetRenderScale(renderer, logicalScale, logicalScale);
|
|
|
|
switch (state)
|
|
{
|
|
case AppState::Loading:
|
|
{
|
|
float contentScale = logicalScale;
|
|
float contentW = LOGICAL_W * contentScale;
|
|
float contentH = LOGICAL_H * contentScale;
|
|
float contentOffsetX = (winW - contentW) * 0.5f / contentScale;
|
|
float contentOffsetY = (winH - contentH) * 0.5f / contentScale;
|
|
|
|
auto drawRect = [&](float x, float y, float w, float h, SDL_Color c)
|
|
{ RenderPrimitives::fillRect(renderer, x + contentOffsetX, y + contentOffsetY, w, h, c); };
|
|
|
|
const bool isLimitedHeight = LOGICAL_H < 450;
|
|
const float logoHeight = logoTex ? (isLimitedHeight ? LOGICAL_H * 0.25f : LOGICAL_H * 0.4f) : 0;
|
|
const float loadingTextHeight = 20;
|
|
const float barHeight = 20;
|
|
const float barPaddingVertical = isLimitedHeight ? 15 : 35;
|
|
const float percentTextHeight = 24;
|
|
const float spacingBetweenElements = isLimitedHeight ? 5 : 15;
|
|
|
|
const float totalContentHeight = logoHeight +
|
|
(logoHeight > 0 ? spacingBetweenElements : 0) +
|
|
loadingTextHeight +
|
|
barPaddingVertical +
|
|
barHeight +
|
|
spacingBetweenElements +
|
|
percentTextHeight;
|
|
|
|
float currentY = (LOGICAL_H - totalContentHeight) / 2.0f;
|
|
|
|
if (logoTex)
|
|
{
|
|
const int lw = 872, lh = 273;
|
|
const float maxLogoWidth = std::min(LOGICAL_W * 0.9f, 600.0f);
|
|
const float availableHeight = isLimitedHeight ? LOGICAL_H * 0.25f : LOGICAL_H * 0.4f;
|
|
const float availableWidth = maxLogoWidth;
|
|
|
|
const float scaleFactorWidth = availableWidth / static_cast<float>(lw);
|
|
const float scaleFactorHeight = availableHeight / static_cast<float>(lh);
|
|
const float scaleFactor = std::min(scaleFactorWidth, scaleFactorHeight);
|
|
|
|
const float displayWidth = lw * scaleFactor;
|
|
const float displayHeight = lh * scaleFactor;
|
|
const float logoX = (LOGICAL_W - displayWidth) / 2.0f;
|
|
|
|
SDL_FRect dst{logoX + contentOffsetX, currentY + contentOffsetY, displayWidth, displayHeight};
|
|
SDL_RenderTexture(renderer, logoTex, nullptr, &dst);
|
|
|
|
currentY += displayHeight + spacingBetweenElements;
|
|
}
|
|
|
|
const char* loadingText = "LOADING";
|
|
float textWidth = strlen(loadingText) * 12.0f;
|
|
float textX = (LOGICAL_W - textWidth) / 2.0f;
|
|
pixelFont.draw(renderer, textX + contentOffsetX, currentY + contentOffsetY, loadingText, 1.0f, {255, 204, 0, 255});
|
|
|
|
currentY += loadingTextHeight + barPaddingVertical;
|
|
|
|
const int barW = 400, barH = 20;
|
|
const int bx = (LOGICAL_W - barW) / 2;
|
|
|
|
drawRect(bx - 3, currentY - 3, barW + 6, barH + 6, {68, 68, 80, 255});
|
|
drawRect(bx, currentY, barW, barH, {34, 34, 34, 255});
|
|
drawRect(bx, currentY, int(barW * loadingProgress), barH, {255, 204, 0, 255});
|
|
|
|
currentY += barH + spacingBetweenElements;
|
|
|
|
int percentage = int(loadingProgress * 100);
|
|
char percentText[16];
|
|
std::snprintf(percentText, sizeof(percentText), "%d%%", percentage);
|
|
|
|
float percentWidth = strlen(percentText) * 12.0f;
|
|
float percentX = (LOGICAL_W - percentWidth) / 2.0f;
|
|
pixelFont.draw(renderer, percentX + contentOffsetX, currentY + contentOffsetY, percentText, 1.5f, {255, 204, 0, 255});
|
|
|
|
{
|
|
std::lock_guard<std::mutex> lk(assetLoadErrorsMutex);
|
|
const int maxShow = 5;
|
|
int count = static_cast<int>(assetLoadErrors.size());
|
|
if (count > 0) {
|
|
int start = std::max(0, count - maxShow);
|
|
float errY = currentY + spacingBetweenElements + 8.0f;
|
|
|
|
std::string latest = assetLoadErrors.back();
|
|
std::string shortTitle = "SpaceTris - Missing assets";
|
|
if (!latest.empty()) {
|
|
std::string trimmed = latest;
|
|
if (trimmed.size() > 48) trimmed = trimmed.substr(0, 45) + "...";
|
|
shortTitle += ": ";
|
|
shortTitle += trimmed;
|
|
}
|
|
SDL_SetWindowTitle(window, shortTitle.c_str());
|
|
|
|
FILE* tf = fopen("tetris_trace.log", "a");
|
|
if (tf) {
|
|
fprintf(tf, "Loading error: %s\n", assetLoadErrors.back().c_str());
|
|
fclose(tf);
|
|
}
|
|
|
|
for (int i = start; i < count; ++i) {
|
|
const std::string& msg = assetLoadErrors[i];
|
|
std::string display = msg;
|
|
if (display.size() > 80) display = display.substr(0, 77) + "...";
|
|
pixelFont.draw(renderer, 80 + contentOffsetX, errY + contentOffsetY, display.c_str(), 0.85f, {255, 100, 100, 255});
|
|
errY += 20.0f;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (Settings::instance().isDebugEnabled()) {
|
|
std::string cur;
|
|
{
|
|
std::lock_guard<std::mutex> lk(currentLoadingMutex);
|
|
cur = currentLoadingFile;
|
|
}
|
|
char buf[128];
|
|
int loaded = loadedTasks.load();
|
|
int total = totalLoadingTasks.load();
|
|
std::snprintf(buf, sizeof(buf), "Loaded: %d / %d", loaded, total);
|
|
float debugX = 20.0f + contentOffsetX;
|
|
float debugY = LOGICAL_H - 48.0f + contentOffsetY;
|
|
pixelFont.draw(renderer, debugX, debugY, buf, 0.9f, SDL_Color{200,200,200,255});
|
|
if (!cur.empty()) {
|
|
std::string display = "Loading: ";
|
|
display += cur;
|
|
if (display.size() > 80) display = display.substr(0,77) + "...";
|
|
pixelFont.draw(renderer, debugX, debugY + 18.0f, display.c_str(), 0.85f, SDL_Color{200,180,120,255});
|
|
}
|
|
}
|
|
}
|
|
break;
|
|
case AppState::Menu:
|
|
if (!mainScreenTex) {
|
|
mainScreenTex = assetLoader.getTexture(Assets::MAIN_SCREEN);
|
|
}
|
|
if (!mainScreenTex) {
|
|
SDL_Texture* loaded = textureLoader->loadFromImage(renderer, Assets::MAIN_SCREEN, &mainScreenW, &mainScreenH);
|
|
if (loaded) {
|
|
assetLoader.adoptTexture(Assets::MAIN_SCREEN, loaded);
|
|
mainScreenTex = loaded;
|
|
}
|
|
}
|
|
if (menuState) {
|
|
menuState->drawMainButtonNormally = false;
|
|
menuState->render(renderer, logicalScale, logicalVP);
|
|
}
|
|
if (mainScreenTex) {
|
|
SDL_SetRenderViewport(renderer, nullptr);
|
|
SDL_SetRenderScale(renderer, 1.f, 1.f);
|
|
float texW = mainScreenW > 0 ? static_cast<float>(mainScreenW) : 0.0f;
|
|
float texH = mainScreenH > 0 ? static_cast<float>(mainScreenH) : 0.0f;
|
|
if (texW <= 0.0f || texH <= 0.0f) {
|
|
float iwf = 0.0f, ihf = 0.0f;
|
|
if (!SDL_GetTextureSize(mainScreenTex, &iwf, &ihf)) {
|
|
iwf = ihf = 0.0f;
|
|
}
|
|
texW = iwf;
|
|
texH = ihf;
|
|
}
|
|
if (texW > 0.0f && texH > 0.0f) {
|
|
const float drawH = static_cast<float>(winH);
|
|
const float scale = drawH / texH;
|
|
const float drawW = texW * scale;
|
|
SDL_FRect dst{
|
|
(winW - drawW) * 0.5f,
|
|
0.0f,
|
|
drawW,
|
|
drawH
|
|
};
|
|
SDL_SetTextureBlendMode(mainScreenTex, SDL_BLENDMODE_BLEND);
|
|
SDL_SetTextureScaleMode(mainScreenTex, SDL_SCALEMODE_LINEAR);
|
|
SDL_RenderTexture(renderer, mainScreenTex, nullptr, &dst);
|
|
}
|
|
SDL_SetRenderViewport(renderer, &logicalVP);
|
|
SDL_SetRenderScale(renderer, logicalScale, logicalScale);
|
|
}
|
|
if (menuState) {
|
|
menuState->renderMainButtonTop(renderer, logicalScale, logicalVP);
|
|
}
|
|
break;
|
|
case AppState::Options:
|
|
optionsState->render(renderer, logicalScale, logicalVP);
|
|
break;
|
|
case AppState::LevelSelector:
|
|
levelSelectorState->render(renderer, logicalScale, logicalVP);
|
|
break;
|
|
case AppState::LevelSelect:
|
|
{
|
|
const std::string title = "SELECT LEVEL";
|
|
int tW = 0, tH = 0;
|
|
font.measure(title, 2.5f, tW, tH);
|
|
float titleX = (LOGICAL_W - (float)tW) / 2.0f;
|
|
font.draw(renderer, titleX, 80, title, 2.5f, SDL_Color{255, 220, 0, 255});
|
|
|
|
char buf[64];
|
|
std::snprintf(buf, sizeof(buf), "LEVEL: %d", startLevelSelection);
|
|
font.draw(renderer, LOGICAL_W * 0.5f - 80, 180, buf, 2.0f, SDL_Color{200, 240, 255, 255});
|
|
font.draw(renderer, LOGICAL_W * 0.5f - 180, 260, "ARROWS CHANGE ENTER=OK ESC=BACK", 1.2f, SDL_Color{200, 200, 220, 255});
|
|
}
|
|
break;
|
|
case AppState::Playing:
|
|
playingState->render(renderer, logicalScale, logicalVP);
|
|
break;
|
|
case AppState::GameOver:
|
|
GameRenderer::renderPlayingState(
|
|
renderer,
|
|
game.get(),
|
|
&pixelFont,
|
|
&lineEffect,
|
|
blocksTex,
|
|
asteroidsTex,
|
|
ctx.statisticsPanelTex,
|
|
scorePanelTex,
|
|
nextPanelTex,
|
|
holdPanelTex,
|
|
false,
|
|
(float)LOGICAL_W,
|
|
(float)LOGICAL_H,
|
|
logicalScale,
|
|
(float)winW,
|
|
(float)winH
|
|
);
|
|
|
|
{
|
|
SDL_SetRenderViewport(renderer, nullptr);
|
|
SDL_SetRenderDrawColor(renderer, 0, 0, 0, 180);
|
|
SDL_FRect fullWin{0.f, 0.f, (float)winW, (float)winH};
|
|
SDL_RenderFillRect(renderer, &fullWin);
|
|
|
|
SDL_SetRenderViewport(renderer, &logicalVP);
|
|
SDL_SetRenderScale(renderer, logicalScale, logicalScale);
|
|
|
|
float contentScale = logicalScale;
|
|
float contentW = LOGICAL_W * contentScale;
|
|
float contentH = LOGICAL_H * contentScale;
|
|
float contentOffsetX = (winW - contentW) * 0.5f / contentScale;
|
|
float contentOffsetY = (winH - contentH) * 0.5f / contentScale;
|
|
|
|
float boxW = 500.0f;
|
|
float boxH = 350.0f;
|
|
float boxX = (LOGICAL_W - boxW) * 0.5f;
|
|
float boxY = (LOGICAL_H - boxH) * 0.5f;
|
|
|
|
SDL_SetRenderDrawColor(renderer, 20, 25, 35, 255);
|
|
SDL_FRect boxRect{boxX + contentOffsetX, boxY + contentOffsetY, boxW, boxH};
|
|
SDL_RenderFillRect(renderer, &boxRect);
|
|
|
|
SDL_SetRenderDrawColor(renderer, 100, 120, 200, 255);
|
|
SDL_FRect borderRect{boxX + contentOffsetX - 3, boxY + contentOffsetY - 3, boxW + 6, boxH + 6};
|
|
SDL_RenderFillRect(renderer, &borderRect);
|
|
SDL_SetRenderDrawColor(renderer, 20, 25, 35, 255);
|
|
SDL_RenderFillRect(renderer, &boxRect);
|
|
|
|
ensureScoresLoaded();
|
|
bool realHighScore = scores.isHighScore(game->score());
|
|
const char* title = realHighScore ? "NEW HIGH SCORE!" : "GAME OVER";
|
|
int tW=0, tH=0; pixelFont.measure(title, 2.0f, tW, tH);
|
|
pixelFont.draw(renderer, boxX + (boxW - tW) * 0.5f + contentOffsetX, boxY + 40 + contentOffsetY, title, 2.0f, realHighScore ? SDL_Color{255, 220, 0, 255} : SDL_Color{255, 60, 60, 255});
|
|
|
|
char scoreStr[64];
|
|
snprintf(scoreStr, sizeof(scoreStr), "SCORE: %d", game->score());
|
|
int sW=0, sH=0; pixelFont.measure(scoreStr, 1.2f, sW, sH);
|
|
pixelFont.draw(renderer, boxX + (boxW - sW) * 0.5f + contentOffsetX, boxY + 100 + contentOffsetY, scoreStr, 1.2f, {255, 255, 255, 255});
|
|
|
|
if (isNewHighScore) {
|
|
const char* enterName = "ENTER NAME:";
|
|
int enW=0, enH=0; pixelFont.measure(enterName, 1.0f, enW, enH);
|
|
pixelFont.draw(renderer, boxX + (boxW - enW) * 0.5f + contentOffsetX, boxY + 160 + contentOffsetY, enterName, 1.0f, {200, 200, 220, 255});
|
|
|
|
float inputW = 300.0f;
|
|
float inputH = 40.0f;
|
|
float inputX = boxX + (boxW - inputW) * 0.5f;
|
|
float inputY = boxY + 200.0f;
|
|
|
|
SDL_SetRenderDrawColor(renderer, 0, 0, 0, 255);
|
|
SDL_FRect inputRect{inputX + contentOffsetX, inputY + contentOffsetY, inputW, inputH};
|
|
SDL_RenderFillRect(renderer, &inputRect);
|
|
|
|
SDL_SetRenderDrawColor(renderer, 255, 220, 0, 255);
|
|
SDL_RenderRect(renderer, &inputRect);
|
|
|
|
const float nameScale = 1.2f;
|
|
const bool showCursor = ((SDL_GetTicks() / 500) % 2) == 0;
|
|
|
|
int metricsW = 0, metricsH = 0;
|
|
pixelFont.measure("A", nameScale, metricsW, metricsH);
|
|
if (metricsH == 0) metricsH = 24;
|
|
|
|
int nameW = 0, nameH = 0;
|
|
if (!playerName.empty()) {
|
|
pixelFont.measure(playerName, nameScale, nameW, nameH);
|
|
} else {
|
|
nameH = metricsH;
|
|
}
|
|
|
|
float textX = inputX + (inputW - static_cast<float>(nameW)) * 0.5f + contentOffsetX;
|
|
float textY = inputY + (inputH - static_cast<float>(metricsH)) * 0.5f + contentOffsetY;
|
|
|
|
if (!playerName.empty()) {
|
|
pixelFont.draw(renderer, textX, textY, playerName, nameScale, {255, 255, 255, 255});
|
|
}
|
|
|
|
if (showCursor) {
|
|
int cursorW = 0, cursorH = 0;
|
|
pixelFont.measure("_", nameScale, cursorW, cursorH);
|
|
float cursorX = playerName.empty()
|
|
? inputX + (inputW - static_cast<float>(cursorW)) * 0.5f + contentOffsetX
|
|
: textX + static_cast<float>(nameW);
|
|
float cursorY = inputY + (inputH - static_cast<float>(cursorH)) * 0.5f + contentOffsetY;
|
|
pixelFont.draw(renderer, cursorX, cursorY, "_", nameScale, {255, 255, 255, 255});
|
|
}
|
|
|
|
const char* hint = "PRESS ENTER TO SUBMIT";
|
|
int hW=0, hH=0; pixelFont.measure(hint, 0.8f, hW, hH);
|
|
pixelFont.draw(renderer, boxX + (boxW - hW) * 0.5f + contentOffsetX, boxY + 280 + contentOffsetY, hint, 0.8f, {150, 150, 150, 255});
|
|
|
|
} else {
|
|
char linesStr[64];
|
|
snprintf(linesStr, sizeof(linesStr), "LINES: %d", game->lines());
|
|
int lW=0, lH=0; pixelFont.measure(linesStr, 1.2f, lW, lH);
|
|
pixelFont.draw(renderer, boxX + (boxW - lW) * 0.5f + contentOffsetX, boxY + 140 + contentOffsetY, linesStr, 1.2f, {255, 255, 255, 255});
|
|
|
|
char levelStr[64];
|
|
snprintf(levelStr, sizeof(levelStr), "LEVEL: %d", game->level());
|
|
int lvW=0, lvH=0; pixelFont.measure(levelStr, 1.2f, lvW, lvH);
|
|
pixelFont.draw(renderer, boxX + (boxW - lvW) * 0.5f + contentOffsetX, boxY + 180 + contentOffsetY, levelStr, 1.2f, {255, 255, 255, 255});
|
|
|
|
const char* instr = "PRESS ENTER TO RESTART";
|
|
int iW=0, iH=0; pixelFont.measure(instr, 0.9f, iW, iH);
|
|
pixelFont.draw(renderer, boxX + (boxW - iW) * 0.5f + contentOffsetX, boxY + 260 + contentOffsetY, instr, 0.9f, {255, 220, 0, 255});
|
|
|
|
const char* instr2 = "PRESS ESC FOR MENU";
|
|
int iW2=0, iH2=0; pixelFont.measure(instr2, 0.9f, iW2, iH2);
|
|
pixelFont.draw(renderer, boxX + (boxW - iW2) * 0.5f + contentOffsetX, boxY + 290 + contentOffsetY, instr2, 0.9f, {255, 220, 0, 255});
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
|
|
if (menuFadeAlpha > 0.0f) {
|
|
SDL_SetRenderViewport(renderer, nullptr);
|
|
SDL_SetRenderScale(renderer, 1.f, 1.f);
|
|
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);
|
|
Uint8 alpha = Uint8(std::clamp(menuFadeAlpha, 0.0f, 1.0f) * 255.0f);
|
|
SDL_SetRenderDrawColor(renderer, 0, 0, 0, alpha);
|
|
SDL_FRect fadeRect{0.f, 0.f, (float)winW, (float)winH};
|
|
SDL_RenderFillRect(renderer, &fadeRect);
|
|
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_NONE);
|
|
SDL_SetRenderViewport(renderer, &logicalVP);
|
|
SDL_SetRenderScale(renderer, logicalScale, logicalScale);
|
|
}
|
|
|
|
if (gameplayCountdownActive && state == AppState::Playing) {
|
|
SDL_SetRenderViewport(renderer, nullptr);
|
|
SDL_SetRenderScale(renderer, 1.f, 1.f);
|
|
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);
|
|
|
|
int cappedIndex = std::min(gameplayCountdownIndex, static_cast<int>(GAMEPLAY_COUNTDOWN_LABELS.size()) - 1);
|
|
const char* label = GAMEPLAY_COUNTDOWN_LABELS[cappedIndex];
|
|
bool isFinalCue = (cappedIndex == static_cast<int>(GAMEPLAY_COUNTDOWN_LABELS.size()) - 1);
|
|
float textScale = isFinalCue ? 4.5f : 5.0f;
|
|
int textW = 0, textH = 0;
|
|
pixelFont.measure(label, textScale, textW, textH);
|
|
|
|
float textX = (winW - static_cast<float>(textW)) * 0.5f;
|
|
float textY = (winH - static_cast<float>(textH)) * 0.5f;
|
|
if (gameplayCountdownSource == CountdownSource::ChallengeLevel) {
|
|
char levelBuf[32];
|
|
std::snprintf(levelBuf, sizeof(levelBuf), "LEVEL %d", countdownLevel);
|
|
int lvlW = 0, lvlH = 0;
|
|
float lvlScale = 2.5f;
|
|
pixelFont.measure(levelBuf, lvlScale, lvlW, lvlH);
|
|
float levelX = (winW - static_cast<float>(lvlW)) * 0.5f;
|
|
float levelY = winH * 0.32f;
|
|
pixelFont.draw(renderer, levelX, levelY, levelBuf, lvlScale, SDL_Color{140, 210, 255, 255});
|
|
|
|
char goalBuf[64];
|
|
std::snprintf(goalBuf, sizeof(goalBuf), "ASTEROIDS: %d", countdownGoalAsteroids);
|
|
int goalW = 0, goalH = 0;
|
|
float goalScale = 1.7f;
|
|
pixelFont.measure(goalBuf, goalScale, goalW, goalH);
|
|
float goalX = (winW - static_cast<float>(goalW)) * 0.5f;
|
|
float goalY = levelY + static_cast<float>(lvlH) + 14.0f;
|
|
pixelFont.draw(renderer, goalX, goalY, goalBuf, goalScale, SDL_Color{220, 245, 255, 255});
|
|
|
|
// Optional story/briefing line
|
|
if (!challengeStoryText.empty() && challengeStoryAlpha > 0.0f) {
|
|
SDL_Color storyColor{170, 230, 255, static_cast<Uint8>(std::lround(255.0f * challengeStoryAlpha))};
|
|
SDL_Color shadowColor{0, 0, 0, static_cast<Uint8>(std::lround(160.0f * challengeStoryAlpha))};
|
|
|
|
auto drawCenteredWrapped = [&](const std::string& text, float y, float maxWidth, float scale) {
|
|
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 candidateW = 0, candidateH = 0;
|
|
pixelFont.measure(candidate, scale, candidateW, candidateH);
|
|
if (candidateW > maxWidth && !line.empty()) {
|
|
int lineW = 0, lineH = 0;
|
|
pixelFont.measure(line, scale, lineW, lineH);
|
|
float lineX = (winW - static_cast<float>(lineW)) * 0.5f;
|
|
pixelFont.draw(renderer, lineX + 1.0f, cursorY + 1.0f, line, scale, shadowColor);
|
|
pixelFont.draw(renderer, lineX, cursorY, line, scale, storyColor);
|
|
cursorY += lineH + 6.0f;
|
|
line = word;
|
|
lastH = lineH;
|
|
} else {
|
|
line = candidate;
|
|
lastH = candidateH;
|
|
}
|
|
}
|
|
if (!line.empty()) {
|
|
int w = 0, h = 0;
|
|
pixelFont.measure(line, scale, w, h);
|
|
float lineX = (winW - static_cast<float>(w)) * 0.5f;
|
|
pixelFont.draw(renderer, lineX + 1.0f, cursorY + 1.0f, line, scale, shadowColor);
|
|
pixelFont.draw(renderer, lineX, cursorY, line, scale, storyColor);
|
|
cursorY += h + 6.0f;
|
|
}
|
|
return cursorY;
|
|
};
|
|
|
|
float storyStartY = goalY + static_cast<float>(goalH) + 22.0f;
|
|
float usedY = drawCenteredWrapped(challengeStoryText, storyStartY, std::min<float>(winW * 0.7f, 720.0f), 1.0f);
|
|
float promptY = usedY + 10.0f;
|
|
if (challengeCountdownWaitingForSpace) {
|
|
const char* prompt = "PRESS SPACE";
|
|
int pW = 0, pH = 0;
|
|
float pScale = 1.35f;
|
|
pixelFont.measure(prompt, pScale, pW, pH);
|
|
float px = (winW - static_cast<float>(pW)) * 0.5f;
|
|
pixelFont.draw(renderer, px + 2.0f, promptY + 2.0f, prompt, pScale, SDL_Color{0, 0, 0, 200});
|
|
pixelFont.draw(renderer, px, promptY, prompt, pScale, SDL_Color{255, 220, 40, 255});
|
|
promptY += pH + 14.0f;
|
|
}
|
|
textY = promptY + 10.0f;
|
|
} else {
|
|
if (challengeCountdownWaitingForSpace) {
|
|
const char* prompt = "PRESS SPACE";
|
|
int pW = 0, pH = 0;
|
|
float pScale = 1.35f;
|
|
pixelFont.measure(prompt, pScale, pW, pH);
|
|
float px = (winW - static_cast<float>(pW)) * 0.5f;
|
|
float py = goalY + static_cast<float>(goalH) + 18.0f;
|
|
pixelFont.draw(renderer, px + 2.0f, py + 2.0f, prompt, pScale, SDL_Color{0, 0, 0, 200});
|
|
pixelFont.draw(renderer, px, py, prompt, pScale, SDL_Color{255, 220, 40, 255});
|
|
textY = py + pH + 24.0f;
|
|
} else {
|
|
textY = goalY + static_cast<float>(goalH) + 38.0f;
|
|
}
|
|
}
|
|
} else {
|
|
textY = winH * 0.38f;
|
|
}
|
|
if (!(gameplayCountdownSource == CountdownSource::ChallengeLevel && challengeCountdownWaitingForSpace)) {
|
|
SDL_Color textColor = isFinalCue ? SDL_Color{255, 230, 90, 255} : SDL_Color{255, 255, 255, 255};
|
|
pixelFont.draw(renderer, textX, textY, label, textScale, textColor);
|
|
}
|
|
|
|
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_NONE);
|
|
}
|
|
|
|
if (showHelpOverlay) {
|
|
SDL_SetRenderViewport(renderer, &logicalVP);
|
|
SDL_SetRenderScale(renderer, logicalScale, logicalScale);
|
|
float contentOffsetX = 0.0f;
|
|
float contentOffsetY = 0.0f;
|
|
if (logicalScale > 0.0f) {
|
|
float scaledW = LOGICAL_W * logicalScale;
|
|
float scaledH = LOGICAL_H * logicalScale;
|
|
contentOffsetX = (winW - scaledW) * 0.5f / logicalScale;
|
|
contentOffsetY = (winH - scaledH) * 0.5f / logicalScale;
|
|
}
|
|
HelpOverlay::Render(renderer, pixelFont, LOGICAL_W, LOGICAL_H, contentOffsetX, contentOffsetY);
|
|
}
|
|
|
|
SDL_RenderPresent(renderer);
|
|
SDL_SetRenderScale(renderer, 1.f, 1.f);
|
|
}
|
|
}
|
|
|
|
void TetrisApp::Impl::shutdown()
|
|
{
|
|
Settings::instance().save();
|
|
|
|
// BackgroundManager owns its own textures.
|
|
levelBackgrounds.reset();
|
|
|
|
// All textures are owned by AssetLoader (including legacy fallbacks adopted above).
|
|
logoTex = nullptr;
|
|
logoSmallTex = nullptr;
|
|
backgroundTex = nullptr;
|
|
mainScreenTex = nullptr;
|
|
blocksTex = nullptr;
|
|
scorePanelTex = nullptr;
|
|
statisticsPanelTex = nullptr;
|
|
nextPanelTex = nullptr;
|
|
|
|
if (scoreLoader.joinable()) {
|
|
scoreLoader.join();
|
|
if (!ctx.scores) {
|
|
ctx.scores = &scores;
|
|
}
|
|
}
|
|
if (menuTrackLoader.joinable()) {
|
|
menuTrackLoader.join();
|
|
}
|
|
|
|
lineEffect.shutdown();
|
|
Audio::instance().shutdown();
|
|
SoundEffectManager::instance().shutdown();
|
|
|
|
// Destroy textures before tearing down the renderer/window.
|
|
assetLoader.shutdown();
|
|
|
|
pixelFont.shutdown();
|
|
font.shutdown();
|
|
|
|
TTF_Quit();
|
|
|
|
if (renderer) {
|
|
SDL_DestroyRenderer(renderer);
|
|
renderer = nullptr;
|
|
}
|
|
if (window) {
|
|
SDL_DestroyWindow(window);
|
|
window = nullptr;
|
|
}
|
|
SDL_Quit();
|
|
}
|