2052 lines
90 KiB
C++
2052 lines
90 KiB
C++
// main.cpp - Application orchestration (initialization, loop, UI states)
|
|
// High-level only: delegates Tetris logic, scores, background, font rendering.
|
|
|
|
#include <SDL3/SDL.h>
|
|
#include <SDL3/SDL_main.h>
|
|
#include <SDL3_image/SDL_image.h>
|
|
#include <SDL3_ttf/SDL_ttf.h>
|
|
#include <string>
|
|
#include <cstdio>
|
|
#include <algorithm>
|
|
#include <array>
|
|
#include <vector>
|
|
#include <random>
|
|
#include <cmath>
|
|
#include <cstdlib>
|
|
#include <memory>
|
|
#include <filesystem>
|
|
#include <thread>
|
|
#include <atomic>
|
|
|
|
#include "audio/Audio.h"
|
|
#include "audio/SoundEffect.h"
|
|
|
|
#include "gameplay/core/Game.h"
|
|
#include "persistence/Scores.h"
|
|
#include "graphics/effects/Starfield.h"
|
|
#include "graphics/effects/Starfield3D.h"
|
|
#include "graphics/ui/Font.h"
|
|
#include "graphics/ui/HelpOverlay.h"
|
|
#include "gameplay/effects/LineEffect.h"
|
|
#include "states/State.h"
|
|
#include "states/LoadingState.h"
|
|
#include "states/MenuState.h"
|
|
#include "states/OptionsState.h"
|
|
#include "states/LevelSelectorState.h"
|
|
#include "states/PlayingState.h"
|
|
#include "audio/MenuWrappers.h"
|
|
#include "utils/ImagePathResolver.h"
|
|
#include "graphics/renderers/GameRenderer.h"
|
|
#include "core/Config.h"
|
|
#include "core/Settings.h"
|
|
|
|
// Debug logging removed: no-op in this build (previously LOG_DEBUG)
|
|
|
|
// Font rendering now handled by FontAtlas
|
|
|
|
// ---------- 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;
|
|
|
|
// Piece types now declared in Game.h
|
|
|
|
// Scores now managed by ScoreManager
|
|
|
|
// 4x4 shapes encoded as 16-bit bitmasks per rotation (row-major 4x4).
|
|
// Bit 0 = (x=0,y=0), Bit 1 = (1,0) ... Bit 15 = (3,3)
|
|
// Shapes & game logic now in Game.cpp
|
|
|
|
// (removed inline shapes)
|
|
|
|
// Piece struct now in Game.h
|
|
|
|
// Game struct replaced by Game class
|
|
|
|
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 void drawRect(SDL_Renderer *r, float x, float y, float w, float h, SDL_Color c)
|
|
{
|
|
SDL_SetRenderDrawColor(r, c.r, c.g, c.b, c.a);
|
|
SDL_FRect fr{x, y, w, h};
|
|
SDL_RenderFillRect(r, &fr);
|
|
}
|
|
|
|
static SDL_Texture* loadTextureFromImage(SDL_Renderer* renderer, const std::string& path, int* outW = nullptr, int* outH = nullptr) {
|
|
if (!renderer) {
|
|
return nullptr;
|
|
}
|
|
|
|
const std::string resolvedPath = AssetPath::resolveImagePath(path);
|
|
SDL_Surface* surface = IMG_Load(resolvedPath.c_str());
|
|
if (!surface) {
|
|
SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "Failed to load image %s (resolved: %s): %s", path.c_str(), resolvedPath.c_str(), SDL_GetError());
|
|
return nullptr;
|
|
}
|
|
|
|
if (outW) { *outW = surface->w; }
|
|
if (outH) { *outH = surface->h; }
|
|
|
|
SDL_Texture* texture = SDL_CreateTextureFromSurface(renderer, surface);
|
|
SDL_DestroySurface(surface);
|
|
|
|
if (!texture) {
|
|
SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "Failed to create texture from %s: %s", resolvedPath.c_str(), SDL_GetError());
|
|
return nullptr;
|
|
}
|
|
|
|
if (resolvedPath != path) {
|
|
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Loaded %s via %s", path.c_str(), resolvedPath.c_str());
|
|
}
|
|
|
|
return texture;
|
|
}
|
|
|
|
enum class LevelBackgroundPhase { Idle, ZoomOut, ZoomIn };
|
|
|
|
struct LevelBackgroundFader {
|
|
SDL_Texture* currentTex = nullptr;
|
|
SDL_Texture* nextTex = nullptr;
|
|
int currentLevel = -1;
|
|
int queuedLevel = -1;
|
|
float phaseElapsedMs = 0.0f;
|
|
float phaseDurationMs = 0.0f;
|
|
float fadeDurationMs = Config::Gameplay::LEVEL_FADE_DURATION;
|
|
LevelBackgroundPhase phase = LevelBackgroundPhase::Idle;
|
|
};
|
|
|
|
static float getPhaseDurationMs(const LevelBackgroundFader& fader, LevelBackgroundPhase phase) {
|
|
const float total = std::max(1200.0f, fader.fadeDurationMs);
|
|
switch (phase) {
|
|
case LevelBackgroundPhase::ZoomOut: return total * 0.45f;
|
|
case LevelBackgroundPhase::ZoomIn: return total * 0.45f;
|
|
case LevelBackgroundPhase::Idle:
|
|
default: return 0.0f;
|
|
}
|
|
}
|
|
|
|
static void setPhase(LevelBackgroundFader& fader, LevelBackgroundPhase nextPhase) {
|
|
fader.phase = nextPhase;
|
|
fader.phaseDurationMs = getPhaseDurationMs(fader, nextPhase);
|
|
fader.phaseElapsedMs = 0.0f;
|
|
}
|
|
|
|
static void destroyTexture(SDL_Texture*& tex) {
|
|
if (tex) {
|
|
SDL_DestroyTexture(tex);
|
|
tex = nullptr;
|
|
}
|
|
}
|
|
|
|
static bool queueLevelBackground(LevelBackgroundFader& fader, SDL_Renderer* renderer, int level) {
|
|
if (!renderer) {
|
|
return false;
|
|
}
|
|
|
|
level = std::clamp(level, 0, 32);
|
|
if (fader.currentLevel == level || fader.queuedLevel == level) {
|
|
return true;
|
|
}
|
|
|
|
char bgPath[256];
|
|
std::snprintf(bgPath, sizeof(bgPath), "assets/images/tetris_main_back_level%d.jpg", level);
|
|
|
|
SDL_Texture* newTexture = loadTextureFromImage(renderer, bgPath);
|
|
if (!newTexture) {
|
|
SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "Failed to queue background for level %d: %s", level, bgPath);
|
|
return false;
|
|
}
|
|
|
|
destroyTexture(fader.nextTex);
|
|
fader.nextTex = newTexture;
|
|
fader.queuedLevel = level;
|
|
|
|
if (!fader.currentTex) {
|
|
// First background load happens instantly.
|
|
fader.currentTex = fader.nextTex;
|
|
fader.currentLevel = fader.queuedLevel;
|
|
fader.nextTex = nullptr;
|
|
fader.queuedLevel = -1;
|
|
fader.phase = LevelBackgroundPhase::Idle;
|
|
fader.phaseElapsedMs = 0.0f;
|
|
fader.phaseDurationMs = 0.0f;
|
|
} else if (fader.phase == LevelBackgroundPhase::Idle) {
|
|
// Kick off fancy transition.
|
|
setPhase(fader, LevelBackgroundPhase::ZoomOut);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
static void updateLevelBackgroundFade(LevelBackgroundFader& fader, float frameMs) {
|
|
if (fader.phase == LevelBackgroundPhase::Idle) {
|
|
return;
|
|
}
|
|
|
|
// Guard against missing textures
|
|
if (!fader.currentTex && !fader.nextTex) {
|
|
fader.phase = LevelBackgroundPhase::Idle;
|
|
return;
|
|
}
|
|
|
|
fader.phaseElapsedMs += frameMs;
|
|
if (fader.phaseElapsedMs < std::max(1.0f, fader.phaseDurationMs)) {
|
|
return;
|
|
}
|
|
|
|
switch (fader.phase) {
|
|
case LevelBackgroundPhase::ZoomOut:
|
|
// After zoom-out, swap textures then start zoom-in.
|
|
if (fader.nextTex) {
|
|
destroyTexture(fader.currentTex);
|
|
fader.currentTex = fader.nextTex;
|
|
fader.currentLevel = fader.queuedLevel;
|
|
fader.nextTex = nullptr;
|
|
fader.queuedLevel = -1;
|
|
}
|
|
setPhase(fader, LevelBackgroundPhase::ZoomIn);
|
|
break;
|
|
case LevelBackgroundPhase::ZoomIn:
|
|
fader.phase = LevelBackgroundPhase::Idle;
|
|
fader.phaseElapsedMs = 0.0f;
|
|
fader.phaseDurationMs = 0.0f;
|
|
break;
|
|
case LevelBackgroundPhase::Idle:
|
|
default:
|
|
fader.phase = LevelBackgroundPhase::Idle;
|
|
break;
|
|
}
|
|
}
|
|
|
|
static void renderScaledBackground(SDL_Renderer* renderer, SDL_Texture* tex, int winW, int winH, float scale, Uint8 alpha = 255) {
|
|
if (!renderer || !tex) {
|
|
return;
|
|
}
|
|
|
|
scale = std::max(0.5f, scale);
|
|
SDL_FRect dest{
|
|
(winW - winW * scale) * 0.5f,
|
|
(winH - winH * scale) * 0.5f,
|
|
winW * scale,
|
|
winH * scale
|
|
};
|
|
|
|
SDL_SetTextureAlphaMod(tex, alpha);
|
|
SDL_RenderTexture(renderer, tex, nullptr, &dest);
|
|
SDL_SetTextureAlphaMod(tex, 255);
|
|
}
|
|
|
|
static void drawOverlay(SDL_Renderer* renderer, const SDL_FRect& rect, SDL_Color color, Uint8 alpha) {
|
|
if (!renderer || alpha == 0) {
|
|
return;
|
|
}
|
|
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);
|
|
SDL_SetRenderDrawColor(renderer, color.r, color.g, color.b, alpha);
|
|
SDL_RenderFillRect(renderer, &rect);
|
|
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_NONE);
|
|
}
|
|
|
|
static void renderLevelBackgrounds(const LevelBackgroundFader& fader, SDL_Renderer* renderer, int winW, int winH) {
|
|
if (!renderer) {
|
|
return;
|
|
}
|
|
|
|
SDL_FRect fullRect{0.f, 0.f, static_cast<float>(winW), static_cast<float>(winH)};
|
|
const float duration = std::max(1.0f, fader.phaseDurationMs);
|
|
const float progress = (fader.phase == LevelBackgroundPhase::Idle) ? 0.0f : std::clamp(fader.phaseElapsedMs / duration, 0.0f, 1.0f);
|
|
|
|
switch (fader.phase) {
|
|
case LevelBackgroundPhase::ZoomOut: {
|
|
const float scale = 1.0f + progress * 0.15f;
|
|
if (fader.currentTex) {
|
|
renderScaledBackground(renderer, fader.currentTex, winW, winH, scale, Uint8((1.0f - progress * 0.4f) * 255.0f));
|
|
drawOverlay(renderer, fullRect, SDL_Color{0, 0, 0, 255}, Uint8(progress * 200.0f));
|
|
}
|
|
break;
|
|
}
|
|
case LevelBackgroundPhase::ZoomIn: {
|
|
const float scale = 1.10f - progress * 0.10f;
|
|
const Uint8 alpha = Uint8((0.4f + progress * 0.6f) * 255.0f);
|
|
if (fader.currentTex) {
|
|
renderScaledBackground(renderer, fader.currentTex, winW, winH, scale, alpha);
|
|
}
|
|
break;
|
|
}
|
|
case LevelBackgroundPhase::Idle:
|
|
default:
|
|
if (fader.currentTex) {
|
|
renderScaledBackground(renderer, fader.currentTex, winW, winH, 1.0f, 255);
|
|
} else if (fader.nextTex) {
|
|
renderScaledBackground(renderer, fader.nextTex, winW, winH, 1.0f, 255);
|
|
} else {
|
|
drawOverlay(renderer, fullRect, SDL_Color{0, 0, 0, 255}, 255);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
static void resetLevelBackgrounds(LevelBackgroundFader& fader) {
|
|
destroyTexture(fader.currentTex);
|
|
destroyTexture(fader.nextTex);
|
|
fader.currentLevel = -1;
|
|
fader.queuedLevel = -1;
|
|
fader.phaseElapsedMs = 0.0f;
|
|
fader.phaseDurationMs = 0.0f;
|
|
fader.phase = LevelBackgroundPhase::Idle;
|
|
}
|
|
|
|
// Hover state for level popup ( -1 = none, 0..19 = hovered level )
|
|
// Now managed by LevelSelectorState
|
|
|
|
// ...existing code...
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Enhanced Button Drawing
|
|
// -----------------------------------------------------------------------------
|
|
static void drawEnhancedButton(SDL_Renderer* renderer, FontAtlas& font, float cx, float cy, float w, float h,
|
|
const std::string& label, bool isHovered, bool isSelected = false) {
|
|
SDL_Color bgColor = isHovered ? SDL_Color{120, 150, 240, 255} : SDL_Color{80, 110, 200, 255};
|
|
if (isSelected) bgColor = {160, 190, 255, 255};
|
|
|
|
float x = cx - w/2;
|
|
float y = cy - h/2;
|
|
|
|
// Draw button background with border
|
|
drawRect(renderer, x-2, y-2, w+4, h+4, {60, 80, 140, 255}); // Border
|
|
drawRect(renderer, x, y, w, h, bgColor); // Background
|
|
|
|
// Draw button text centered
|
|
float textScale = 1.5f;
|
|
float textX = x + (w - label.length() * 12 * textScale) / 2;
|
|
float textY = y + (h - 20 * textScale) / 2;
|
|
font.draw(renderer, textX, textY, label, textScale, {255, 255, 255, 255});
|
|
}
|
|
|
|
// External wrapper for enhanced button so other translation units can call it.
|
|
void menu_drawEnhancedButton(SDL_Renderer* renderer, FontAtlas& font, float cx, float cy, float w, float h,
|
|
const std::string& label, bool isHovered, bool isSelected) {
|
|
drawEnhancedButton(renderer, font, cx, cy, w, h, label, isHovered, isSelected);
|
|
}
|
|
|
|
// Popup wrappers
|
|
// Forward declarations for popup functions defined later in this file
|
|
static void drawSettingsPopup(SDL_Renderer* renderer, FontAtlas& font, bool musicEnabled);
|
|
|
|
void menu_drawSettingsPopup(SDL_Renderer* renderer, FontAtlas& font, bool musicEnabled) {
|
|
drawSettingsPopup(renderer, font, musicEnabled);
|
|
}
|
|
|
|
// Simple rounded menu button drawer used by MenuState (keeps visual parity with JS)
|
|
void menu_drawMenuButton(SDL_Renderer* renderer, FontAtlas& font, float cx, float cy, float w, float h,
|
|
const std::string& label, SDL_Color bgColor, SDL_Color borderColor) {
|
|
float x = cx - w/2;
|
|
float y = cy - h/2;
|
|
drawRect(renderer, x-6, y-6, w+12, h+12, borderColor);
|
|
drawRect(renderer, x-4, y-4, w+8, h+8, {255,255,255,255});
|
|
drawRect(renderer, x, y, w, h, bgColor);
|
|
|
|
float textScale = 1.6f;
|
|
float approxCharW = 12.0f * textScale;
|
|
float textW = label.length() * approxCharW;
|
|
float tx = x + (w - textW) / 2.0f;
|
|
float ty = y + (h - 20.0f * textScale) / 2.0f;
|
|
font.draw(renderer, tx+2, ty+2, label, textScale, {0,0,0,180});
|
|
font.draw(renderer, tx, ty, label, textScale, {255,255,255,255});
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Block Drawing Functions
|
|
// -----------------------------------------------------------------------------
|
|
static void drawBlockTexture(SDL_Renderer* renderer, SDL_Texture* blocksTex, float x, float y, float size, int blockType) {
|
|
if (!blocksTex || blockType < 0 || blockType >= PIECE_COUNT) {
|
|
// Debug: print why we're falling back
|
|
if (!blocksTex) {
|
|
static bool printed = false;
|
|
if (!printed) {
|
|
(void)0;
|
|
printed = true;
|
|
}
|
|
}
|
|
// Fallback to colored rectangle if texture isn't available
|
|
SDL_Color color = (blockType >= 0 && blockType < PIECE_COUNT) ? COLORS[blockType + 1] : SDL_Color{128, 128, 128, 255};
|
|
drawRect(renderer, x, y, size-1, size-1, color);
|
|
return;
|
|
}
|
|
|
|
// JavaScript uses: sx = type * spriteSize, sy = 0, with 2px padding
|
|
// Each sprite is 90px wide in the horizontal sprite sheet
|
|
const int SPRITE_SIZE = 90;
|
|
float srcX = blockType * SPRITE_SIZE + 2; // Add 2px padding like JS
|
|
float srcY = 2; // Add 2px padding from top like JS
|
|
float srcW = SPRITE_SIZE - 4; // Subtract 4px total padding like JS
|
|
float srcH = SPRITE_SIZE - 4; // Subtract 4px total padding like JS
|
|
|
|
SDL_FRect srcRect = {srcX, srcY, srcW, srcH};
|
|
SDL_FRect dstRect = {x, y, size, size};
|
|
SDL_RenderTexture(renderer, blocksTex, &srcRect, &dstRect);
|
|
}
|
|
|
|
static void drawPiece(SDL_Renderer* renderer, SDL_Texture* blocksTex, const Game::Piece& piece, float ox, float oy, float tileSize, bool isGhost = false) {
|
|
if (piece.type >= PIECE_COUNT) return;
|
|
|
|
for (int cy = 0; cy < 4; ++cy) {
|
|
for (int cx = 0; cx < 4; ++cx) {
|
|
if (Game::cellFilled(piece, cx, cy)) {
|
|
float px = ox + (piece.x + cx) * tileSize;
|
|
float py = oy + (piece.y + cy) * tileSize;
|
|
|
|
if (isGhost) {
|
|
|
|
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);
|
|
|
|
// Draw ghost piece as barely visible gray outline
|
|
SDL_SetRenderDrawColor(renderer, 180, 180, 180, 20); // Very faint gray
|
|
SDL_FRect rect = {px + 2, py + 2, tileSize - 4, tileSize - 4};
|
|
SDL_RenderFillRect(renderer, &rect);
|
|
|
|
// Draw thin gray border
|
|
SDL_SetRenderDrawColor(renderer, 180, 180, 180, 30);
|
|
SDL_FRect border = {px + 1, py + 1, tileSize - 2, tileSize - 2};
|
|
SDL_RenderRect(renderer, &border);
|
|
} else {
|
|
drawBlockTexture(renderer, blocksTex, px, py, tileSize, piece.type);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
static void drawSmallPiece(SDL_Renderer* renderer, SDL_Texture* blocksTex, PieceType pieceType, float x, float y, float tileSize) {
|
|
if (pieceType >= PIECE_COUNT) return;
|
|
|
|
// Use the first rotation (index 0) for preview
|
|
Game::Piece previewPiece;
|
|
previewPiece.type = pieceType;
|
|
previewPiece.rot = 0;
|
|
previewPiece.x = 0;
|
|
previewPiece.y = 0;
|
|
|
|
// Center the piece in the preview area
|
|
float offsetX = 0, offsetY = 0;
|
|
if (pieceType == I) { offsetX = tileSize * 0.5f; } // I-piece centering
|
|
else if (pieceType == O) { offsetX = tileSize * 0.5f; } // O-piece centering
|
|
|
|
// Use semi-transparent alpha for preview blocks
|
|
Uint8 previewAlpha = 180; // Change this value for more/less transparency
|
|
SDL_SetTextureAlphaMod(blocksTex, previewAlpha);
|
|
for (int cy = 0; cy < 4; ++cy) {
|
|
for (int cx = 0; cx < 4; ++cx) {
|
|
if (Game::cellFilled(previewPiece, cx, cy)) {
|
|
float px = x + offsetX + cx * tileSize;
|
|
float py = y + offsetY + cy * tileSize;
|
|
drawBlockTexture(renderer, blocksTex, px, py, tileSize, pieceType);
|
|
}
|
|
}
|
|
}
|
|
SDL_SetTextureAlphaMod(blocksTex, 255); // Reset alpha after drawing
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Popup Drawing Functions
|
|
// -----------------------------------------------------------------------------
|
|
static void drawSettingsPopup(SDL_Renderer* renderer, FontAtlas& font, bool musicEnabled) {
|
|
float popupW = 350, popupH = 260;
|
|
float popupX = (LOGICAL_W - popupW) / 2;
|
|
float popupY = (LOGICAL_H - popupH) / 2;
|
|
|
|
// Semi-transparent overlay
|
|
SDL_SetRenderDrawColor(renderer, 0, 0, 0, 128);
|
|
SDL_FRect overlay{0, 0, LOGICAL_W, LOGICAL_H};
|
|
SDL_RenderFillRect(renderer, &overlay);
|
|
|
|
// Popup background
|
|
drawRect(renderer, popupX-4, popupY-4, popupW+8, popupH+8, {100, 120, 160, 255}); // Border
|
|
drawRect(renderer, popupX, popupY, popupW, popupH, {40, 50, 70, 255}); // Background
|
|
|
|
// Title
|
|
font.draw(renderer, popupX + 20, popupY + 20, "SETTINGS", 2.0f, {255, 220, 0, 255});
|
|
|
|
// Music toggle
|
|
font.draw(renderer, popupX + 20, popupY + 70, "MUSIC:", 1.5f, {255, 255, 255, 255});
|
|
const char* musicStatus = musicEnabled ? "ON" : "OFF";
|
|
SDL_Color musicColor = musicEnabled ? SDL_Color{0, 255, 0, 255} : SDL_Color{255, 0, 0, 255};
|
|
font.draw(renderer, popupX + 120, popupY + 70, musicStatus, 1.5f, musicColor);
|
|
|
|
// Sound effects toggle
|
|
font.draw(renderer, popupX + 20, popupY + 100, "SOUND FX:", 1.5f, {255, 255, 255, 255});
|
|
const char* soundStatus = SoundEffectManager::instance().isEnabled() ? "ON" : "OFF";
|
|
SDL_Color soundColor = SoundEffectManager::instance().isEnabled() ? SDL_Color{0, 255, 0, 255} : SDL_Color{255, 0, 0, 255};
|
|
font.draw(renderer, popupX + 140, popupY + 100, soundStatus, 1.5f, soundColor);
|
|
|
|
// Instructions
|
|
font.draw(renderer, popupX + 20, popupY + 150, "M = TOGGLE MUSIC", 1.0f, {200, 200, 220, 255});
|
|
font.draw(renderer, popupX + 20, popupY + 170, "S = TOGGLE SOUND FX", 1.0f, {200, 200, 220, 255});
|
|
font.draw(renderer, popupX + 20, popupY + 190, "ESC = CLOSE", 1.0f, {200, 200, 220, 255});
|
|
}
|
|
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Starfield effect for background
|
|
// -----------------------------------------------------------------------------
|
|
// Starfield now managed by Starfield class
|
|
|
|
// State manager integration (scaffolded in StateManager.h)
|
|
#include "core/state/StateManager.h"
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Intro/Menu state variables
|
|
// -----------------------------------------------------------------------------
|
|
static double logoAnimCounter = 0.0;
|
|
static bool showSettingsPopup = false;
|
|
static bool showHelpOverlay = false;
|
|
static bool showExitConfirmPopup = false;
|
|
static int exitPopupSelectedButton = 1; // 0 = YES, 1 = NO
|
|
static bool musicEnabled = true;
|
|
static int hoveredButton = -1; // -1 = none, 0 = play, 1 = level, 2 = settings
|
|
static bool isNewHighScore = false;
|
|
static std::string playerName = "";
|
|
static bool helpOverlayPausedGame = false;
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Tetris Block Fireworks for intro animation (block particles)
|
|
// Forward declare block render helper used by particles
|
|
static void drawBlockTexture(SDL_Renderer* renderer, SDL_Texture* blocksTex, float x, float y, float size, int blockType);
|
|
// -----------------------------------------------------------------------------
|
|
struct BlockParticle {
|
|
float x{}, y{};
|
|
float vx{}, vy{};
|
|
float size{}, alpha{}, decay{};
|
|
float wobblePhase{}, wobbleSpeed{};
|
|
float coreHeat{};
|
|
BlockParticle(float sx, float sy)
|
|
: x(sx), y(sy) {
|
|
const float spreadDeg = 35.0f;
|
|
const float angleDeg = -90.0f + spreadDeg * ((rand() % 200) / 100.0f - 1.0f); // bias upward
|
|
const float angleRad = angleDeg * 3.1415926f / 180.0f;
|
|
float speed = 1.3f + (rand() % 220) / 80.0f; // ~1.3..4.05
|
|
vx = std::cos(angleRad) * speed * 0.55f;
|
|
vy = std::sin(angleRad) * speed;
|
|
size = 6.0f + (rand() % 40) / 10.0f; // 6..10 px
|
|
alpha = 1.0f;
|
|
decay = 0.0095f + (rand() % 180) / 12000.0f; // 0.0095..0.0245
|
|
wobblePhase = (rand() % 628) / 100.0f;
|
|
wobbleSpeed = 0.08f + (rand() % 60) / 600.0f;
|
|
coreHeat = 0.65f + (rand() % 35) / 100.0f;
|
|
}
|
|
bool update() {
|
|
vx *= 0.992f;
|
|
vy = vy * 0.985f - 0.015f; // buoyancy pushes upward (negative vy)
|
|
x += vx;
|
|
y += vy;
|
|
wobblePhase += wobbleSpeed;
|
|
x += std::sin(wobblePhase) * 0.12f;
|
|
alpha -= decay;
|
|
size = std::max(1.8f, size - 0.03f);
|
|
coreHeat = std::max(0.0f, coreHeat - decay * 0.6f);
|
|
return alpha > 0.03f;
|
|
}
|
|
};
|
|
|
|
struct TetrisFirework {
|
|
std::vector<BlockParticle> particles;
|
|
int mode = 0; // 0=random,1=red,2=green,3=palette
|
|
TetrisFirework(float x, float y) {
|
|
mode = rand() % 4;
|
|
int particleCount = 30 + rand() % 25; // 30-55 particles
|
|
particles.reserve(particleCount);
|
|
for (int i = 0; i < particleCount; ++i) particles.emplace_back(x, y);
|
|
}
|
|
bool update() {
|
|
for (auto it = particles.begin(); it != particles.end();) {
|
|
if (!it->update()) it = particles.erase(it); else ++it;
|
|
}
|
|
return !particles.empty();
|
|
}
|
|
// Drawing is handled by drawFireworks_impl which accepts the texture to use.
|
|
};
|
|
|
|
static std::vector<TetrisFirework> fireworks;
|
|
static Uint64 lastFireworkTime = 0;
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Fireworks Management
|
|
// -----------------------------------------------------------------------------
|
|
static void updateFireworks(double frameMs) {
|
|
Uint64 now = SDL_GetTicks();
|
|
// Randomly spawn new block fireworks (2% chance per frame), bias to lower-right
|
|
if (fireworks.size() < 5 && (rand() % 100) < 2) {
|
|
float x = LOGICAL_W * 0.55f + float(rand() % int(LOGICAL_W * 0.35f));
|
|
float y = LOGICAL_H * 0.80f + float(rand() % int(LOGICAL_H * 0.15f));
|
|
fireworks.emplace_back(x, y);
|
|
lastFireworkTime = now;
|
|
}
|
|
|
|
// Update existing fireworks
|
|
for (auto it = fireworks.begin(); it != fireworks.end();) {
|
|
if (!it->update()) {
|
|
it = fireworks.erase(it);
|
|
} else {
|
|
++it;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Primary implementation that accepts a texture pointer
|
|
static SDL_Color blendFireColor(float heat, float alphaScale, Uint8 minG, Uint8 minB) {
|
|
heat = std::clamp(heat, 0.0f, 1.0f);
|
|
Uint8 r = 255;
|
|
Uint8 g = static_cast<Uint8>(std::clamp(120.0f + heat * (255.0f - 120.0f), float(minG), 255.0f));
|
|
Uint8 b = static_cast<Uint8>(std::clamp(40.0f + (1.0f - heat) * 60.0f, float(minB), 255.0f));
|
|
Uint8 a = static_cast<Uint8>(std::clamp(alphaScale * 255.0f, 0.0f, 255.0f));
|
|
return SDL_Color{r, g, b, a};
|
|
}
|
|
|
|
static void drawFireworks_impl(SDL_Renderer* renderer, SDL_Texture*) {
|
|
SDL_BlendMode previousBlend = SDL_BLENDMODE_NONE;
|
|
SDL_GetRenderDrawBlendMode(renderer, &previousBlend);
|
|
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_ADD);
|
|
|
|
static constexpr int quadIndices[6] = {0, 1, 2, 2, 1, 3};
|
|
|
|
auto makeVertex = [](float px, float py, SDL_Color c) {
|
|
SDL_Vertex v{};
|
|
v.position.x = px;
|
|
v.position.y = py;
|
|
v.color = SDL_FColor{
|
|
c.r / 255.0f,
|
|
c.g / 255.0f,
|
|
c.b / 255.0f,
|
|
c.a / 255.0f
|
|
};
|
|
return v;
|
|
};
|
|
|
|
for (auto& f : fireworks) {
|
|
for (auto &p : f.particles) {
|
|
const float heat = std::clamp(p.alpha * 1.25f + p.coreHeat * 0.5f, 0.0f, 1.0f);
|
|
SDL_Color glowColor = blendFireColor(0.45f + heat * 0.55f, p.alpha * 0.55f, 100, 40);
|
|
SDL_Color tailBaseColor = blendFireColor(heat * 0.75f, p.alpha * 0.5f, 70, 25);
|
|
SDL_Color tailTipColor = blendFireColor(heat * 0.35f, p.alpha * 0.2f, 40, 15);
|
|
SDL_Color coreColor = blendFireColor(heat, std::min(1.0f, p.alpha * 1.1f), 150, 80);
|
|
|
|
float velLen = std::sqrt(p.vx * p.vx + p.vy * p.vy);
|
|
SDL_FPoint dir = velLen > 0.001f ? SDL_FPoint{p.vx / velLen, p.vy / velLen}
|
|
: SDL_FPoint{0.0f, -1.0f};
|
|
SDL_FPoint perp{-dir.y, dir.x};
|
|
|
|
const float baseWidth = std::max(0.8f, p.size * 0.55f);
|
|
const float tipWidth = baseWidth * 0.35f;
|
|
const float tailLen = p.size * (3.0f + (1.0f - p.alpha) * 1.8f);
|
|
|
|
SDL_FPoint base{p.x, p.y};
|
|
SDL_FPoint tip{p.x + dir.x * tailLen, p.y + dir.y * tailLen};
|
|
|
|
SDL_Vertex tailVerts[4];
|
|
tailVerts[0] = makeVertex(base.x + perp.x * baseWidth, base.y + perp.y * baseWidth, tailBaseColor);
|
|
tailVerts[1] = makeVertex(base.x - perp.x * baseWidth, base.y - perp.y * baseWidth, tailBaseColor);
|
|
tailVerts[2] = makeVertex(tip.x + perp.x * tipWidth, tip.y + perp.y * tipWidth, tailTipColor);
|
|
tailVerts[3] = makeVertex(tip.x - perp.x * tipWidth, tip.y - perp.y * tipWidth, tailTipColor);
|
|
SDL_RenderGeometry(renderer, nullptr, tailVerts, 4, quadIndices, 6);
|
|
|
|
const float glowAlong = p.size * 0.95f;
|
|
const float glowAcross = p.size * 0.6f;
|
|
SDL_Vertex glowVerts[4];
|
|
glowVerts[0] = makeVertex(base.x + dir.x * glowAlong, base.y + dir.y * glowAlong, glowColor);
|
|
glowVerts[1] = makeVertex(base.x - dir.x * glowAlong, base.y - dir.y * glowAlong, glowColor);
|
|
glowVerts[2] = makeVertex(base.x + perp.x * glowAcross, base.y + perp.y * glowAcross, glowColor);
|
|
glowVerts[3] = makeVertex(base.x - perp.x * glowAcross, base.y - perp.y * glowAcross, glowColor);
|
|
SDL_RenderGeometry(renderer, nullptr, glowVerts, 4, quadIndices, 6);
|
|
|
|
const float coreWidth = p.size * 0.35f;
|
|
const float coreHeight = p.size * 0.9f;
|
|
SDL_Vertex coreVerts[4];
|
|
coreVerts[0] = makeVertex(base.x + perp.x * coreWidth, base.y + perp.y * coreWidth, coreColor);
|
|
coreVerts[1] = makeVertex(base.x - perp.x * coreWidth, base.y - perp.y * coreWidth, coreColor);
|
|
coreVerts[2] = makeVertex(base.x + dir.x * coreHeight, base.y + dir.y * coreHeight, coreColor);
|
|
coreVerts[3] = makeVertex(base.x - dir.x * coreHeight, base.y - dir.y * coreHeight, coreColor);
|
|
SDL_RenderGeometry(renderer, nullptr, coreVerts, 4, quadIndices, 6);
|
|
}
|
|
}
|
|
|
|
SDL_SetRenderDrawBlendMode(renderer, previousBlend);
|
|
}
|
|
// External wrappers for use by other translation units (MenuState)
|
|
// Expect callers to pass the blocks texture via StateContext so we avoid globals.
|
|
void menu_drawFireworks(SDL_Renderer* renderer, SDL_Texture* blocksTex) { drawFireworks_impl(renderer, blocksTex); }
|
|
void menu_updateFireworks(double frameMs) { updateFireworks(frameMs); }
|
|
double menu_getLogoAnimCounter() { return logoAnimCounter; }
|
|
int menu_getHoveredButton() { return hoveredButton; }
|
|
|
|
int main(int, char **)
|
|
{
|
|
// Initialize random seed for fireworks
|
|
srand(static_cast<unsigned int>(SDL_GetTicks()));
|
|
|
|
// Load settings
|
|
Settings::instance().load();
|
|
|
|
// Sync static 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;
|
|
}
|
|
|
|
SDL_Window *window = SDL_CreateWindow("Tetris (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;
|
|
}
|
|
SDL_Renderer *renderer = SDL_CreateRenderer(window, nullptr);
|
|
if (!renderer)
|
|
{
|
|
SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "SDL_CreateRenderer failed: %s", SDL_GetError());
|
|
SDL_DestroyWindow(window);
|
|
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());
|
|
}
|
|
|
|
FontAtlas font;
|
|
font.init("FreeSans.ttf", 24);
|
|
|
|
// Load PressStart2P font for loading screen and retro UI elements
|
|
FontAtlas pixelFont;
|
|
pixelFont.init("assets/fonts/PressStart2P-Regular.ttf", 16);
|
|
|
|
ScoreManager scores;
|
|
std::atomic<bool> scoresLoadComplete{false};
|
|
// Load scores asynchronously but keep the worker alive until shutdown to avoid lifetime issues
|
|
std::jthread scoreLoader([&scores, &scoresLoadComplete]() {
|
|
scores.load();
|
|
scoresLoadComplete.store(true, std::memory_order_release);
|
|
});
|
|
std::jthread menuTrackLoader;
|
|
Starfield starfield;
|
|
starfield.init(200, LOGICAL_W, LOGICAL_H);
|
|
Starfield3D starfield3D;
|
|
starfield3D.init(LOGICAL_W, LOGICAL_H, 200);
|
|
|
|
// Initialize line clearing effects
|
|
LineEffect lineEffect;
|
|
lineEffect.init(renderer);
|
|
|
|
// Load logo assets via SDL_image so we can use compressed formats
|
|
SDL_Texture* logoTex = loadTextureFromImage(renderer, "assets/images/logo.bmp");
|
|
|
|
// Load small logo (used by Menu to show whole logo)
|
|
int logoSmallW = 0, logoSmallH = 0;
|
|
SDL_Texture* logoSmallTex = loadTextureFromImage(renderer, "assets/images/logo_small.bmp", &logoSmallW, &logoSmallH);
|
|
|
|
// Load menu background using SDL_image (prefers JPEG)
|
|
SDL_Texture* backgroundTex = loadTextureFromImage(renderer, "assets/images/main_background.bmp");
|
|
|
|
// Note: `backgroundTex` is owned by main and passed into `StateContext::backgroundTex` below.
|
|
// States should render using `ctx.backgroundTex` rather than accessing globals.
|
|
|
|
// Level background caching system
|
|
LevelBackgroundFader levelBackgrounds;
|
|
|
|
// Default start level selection: 0 (declare here so it's in scope for all handlers)
|
|
int startLevelSelection = 0;
|
|
|
|
// Load blocks texture via SDL_image (falls back to procedural blocks if missing)
|
|
SDL_Texture* blocksTex = loadTextureFromImage(renderer, "assets/images/blocks90px_001.bmp");
|
|
// No global exposure of blocksTex; states receive textures via StateContext.
|
|
|
|
if (!blocksTex) {
|
|
// Create a 630x90 texture (7 blocks * 90px each)
|
|
blocksTex = SDL_CreateTexture(renderer, SDL_PIXELFORMAT_RGBA8888, SDL_TEXTUREACCESS_TARGET, 630, 90);
|
|
|
|
// Generate blocks by drawing colored rectangles to texture
|
|
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);
|
|
}
|
|
|
|
Game game(startLevelSelection);
|
|
// Apply global gravity speed multiplier from config
|
|
game.setGravityGlobalMultiplier(Config::Gameplay::GRAVITY_SPEED_MULTIPLIER);
|
|
game.reset(startLevelSelection);
|
|
|
|
// Initialize sound effects system
|
|
SoundEffectManager::instance().init();
|
|
|
|
auto loadAudioAsset = [](const std::string& basePath, const std::string& id) {
|
|
std::string resolved = AssetPath::resolveWithExtensions(basePath, { ".wav", ".mp3" });
|
|
if (resolved.empty()) {
|
|
SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "Missing audio asset for %s (base %s)", id.c_str(), basePath.c_str());
|
|
return;
|
|
}
|
|
if (!SoundEffectManager::instance().loadSound(id, resolved)) {
|
|
SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "Failed to load %s from %s", id.c_str(), resolved.c_str());
|
|
}
|
|
};
|
|
|
|
loadAudioAsset("assets/music/clear_line", "clear_line");
|
|
|
|
// Load voice lines for line clears using WAV files (with MP3 fallback)
|
|
std::vector<std::string> singleSounds = {"well_played", "smooth_clear", "great_move"};
|
|
std::vector<std::string> doubleSounds = {"nice_combo", "you_fire", "keep_that_ryhtm"};
|
|
std::vector<std::string> tripleSounds = {"impressive", "triple_strike"};
|
|
std::vector<std::string> tetrisSounds = {"amazing", "you_re_unstoppable", "boom_tetris", "wonderful"};
|
|
std::vector<std::string> allVoiceSounds;
|
|
auto appendVoices = [&allVoiceSounds](const std::vector<std::string>& src) {
|
|
allVoiceSounds.insert(allVoiceSounds.end(), src.begin(), src.end());
|
|
};
|
|
appendVoices(singleSounds);
|
|
appendVoices(doubleSounds);
|
|
appendVoices(tripleSounds);
|
|
appendVoices(tetrisSounds);
|
|
|
|
auto loadVoice = [&](const std::string& id, const std::string& baseName) {
|
|
loadAudioAsset("assets/music/" + baseName, id);
|
|
};
|
|
|
|
loadVoice("nice_combo", "nice_combo");
|
|
loadVoice("you_fire", "you_fire");
|
|
loadVoice("well_played", "well_played");
|
|
loadVoice("keep_that_ryhtm", "keep_that_ryhtm");
|
|
loadVoice("great_move", "great_move");
|
|
loadVoice("smooth_clear", "smooth_clear");
|
|
loadVoice("impressive", "impressive");
|
|
loadVoice("triple_strike", "triple_strike");
|
|
loadVoice("amazing", "amazing");
|
|
loadVoice("you_re_unstoppable", "you_re_unstoppable");
|
|
loadVoice("boom_tetris", "boom_tetris");
|
|
loadVoice("wonderful", "wonderful");
|
|
loadVoice("lets_go", "lets_go");
|
|
loadVoice("hard_drop", "hard_drop_001");
|
|
loadVoice("new_level", "new_level");
|
|
|
|
bool suppressLineVoiceForLevelUp = false;
|
|
|
|
auto playVoiceCue = [&](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);
|
|
}
|
|
};
|
|
|
|
// Set up sound effect callbacks
|
|
game.setSoundCallback([&, playVoiceCue](int linesCleared) {
|
|
if (linesCleared <= 0) {
|
|
return;
|
|
}
|
|
|
|
// Always play the core line-clear sound for consistency
|
|
SoundEffectManager::instance().playSound("clear_line", 1.0f);
|
|
|
|
// Layer a voiced callout based on the number of cleared lines
|
|
if (!suppressLineVoiceForLevelUp) {
|
|
playVoiceCue(linesCleared);
|
|
}
|
|
suppressLineVoiceForLevelUp = false;
|
|
});
|
|
|
|
game.setLevelUpCallback([&](int newLevel) {
|
|
SoundEffectManager::instance().playSound("new_level", 1.0f);
|
|
SoundEffectManager::instance().playSound("lets_go", 1.0f); // Existing voice line
|
|
suppressLineVoiceForLevelUp = true;
|
|
});
|
|
|
|
AppState state = AppState::Loading;
|
|
double loadingProgress = 0.0;
|
|
Uint64 loadStart = SDL_GetTicks();
|
|
bool running = true;
|
|
bool isFullscreen = Settings::instance().isFullscreen();
|
|
bool leftHeld = false, rightHeld = false;
|
|
double moveTimerMs = 0;
|
|
const double DAS = 170.0, ARR = 40.0;
|
|
SDL_Rect logicalVP{0, 0, LOGICAL_W, LOGICAL_H};
|
|
float logicalScale = 1.f;
|
|
Uint64 lastMs = SDL_GetPerformanceCounter();
|
|
bool musicStarted = false;
|
|
bool musicLoaded = false;
|
|
int currentTrackLoading = 0;
|
|
int totalTracks = 0; // Will be set dynamically based on actual files
|
|
|
|
enum class MenuFadePhase { None, FadeOut, FadeIn };
|
|
MenuFadePhase menuFadePhase = MenuFadePhase::None;
|
|
double menuFadeClockMs = 0.0;
|
|
float menuFadeAlpha = 0.0f;
|
|
const double MENU_PLAY_FADE_DURATION_MS = 450.0;
|
|
AppState menuFadeTarget = AppState::Menu;
|
|
bool menuPlayCountdownArmed = false;
|
|
bool gameplayCountdownActive = false;
|
|
double gameplayCountdownElapsed = 0.0;
|
|
int gameplayCountdownIndex = 0;
|
|
const double GAMEPLAY_COUNTDOWN_STEP_MS = 400.0;
|
|
const std::array<const char*, 4> GAMEPLAY_COUNTDOWN_LABELS = { "3", "2", "1", "START" };
|
|
|
|
// Instantiate state manager
|
|
StateManager stateMgr(state);
|
|
|
|
// Prepare shared context for states
|
|
StateContext ctx{};
|
|
// Allow states to access the state manager for transitions
|
|
ctx.stateManager = &stateMgr;
|
|
ctx.game = &game;
|
|
ctx.scores = nullptr; // populated once async load finishes
|
|
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 = backgroundTex;
|
|
ctx.blocksTex = blocksTex;
|
|
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.playerName = &playerName;
|
|
ctx.fullscreenFlag = &isFullscreen;
|
|
ctx.applyFullscreen = [window, &isFullscreen](bool enable) {
|
|
SDL_SetWindowFullscreen(window, enable ? SDL_WINDOW_FULLSCREEN : 0);
|
|
isFullscreen = enable;
|
|
};
|
|
ctx.queryFullscreen = [window]() -> bool {
|
|
return (SDL_GetWindowFlags(window) & SDL_WINDOW_FULLSCREEN) != 0;
|
|
};
|
|
ctx.requestQuit = [&running]() {
|
|
running = false;
|
|
};
|
|
|
|
auto ensureScoresLoaded = [&]() {
|
|
if (scoreLoader.joinable()) {
|
|
scoreLoader.join();
|
|
}
|
|
if (!ctx.scores) {
|
|
ctx.scores = &scores;
|
|
}
|
|
};
|
|
|
|
auto beginStateFade = [&](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) {
|
|
game.setPaused(false);
|
|
}
|
|
};
|
|
|
|
auto startMenuPlayTransition = [&]() {
|
|
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 = [&](AppState targetState) {
|
|
if (!ctx.stateManager) {
|
|
return;
|
|
}
|
|
if (targetState == AppState::Playing) {
|
|
startMenuPlayTransition();
|
|
return;
|
|
}
|
|
beginStateFade(targetState, false);
|
|
};
|
|
ctx.requestFadeTransition = requestStateFade;
|
|
|
|
// Instantiate state objects
|
|
auto loadingState = std::make_unique<LoadingState>(ctx);
|
|
auto menuState = std::make_unique<MenuState>(ctx);
|
|
auto optionsState = std::make_unique<OptionsState>(ctx);
|
|
auto levelSelectorState = std::make_unique<LevelSelectorState>(ctx);
|
|
auto playingState = std::make_unique<PlayingState>(ctx);
|
|
|
|
// Register handlers and lifecycle hooks
|
|
stateMgr.registerHandler(AppState::Loading, [&](const SDL_Event& e){ loadingState->handleEvent(e); });
|
|
stateMgr.registerOnEnter(AppState::Loading, [&](){ loadingState->onEnter(); });
|
|
stateMgr.registerOnExit(AppState::Loading, [&](){ loadingState->onExit(); });
|
|
|
|
stateMgr.registerHandler(AppState::Menu, [&](const SDL_Event& e){ menuState->handleEvent(e); });
|
|
stateMgr.registerOnEnter(AppState::Menu, [&](){ menuState->onEnter(); });
|
|
stateMgr.registerOnExit(AppState::Menu, [&](){ menuState->onExit(); });
|
|
|
|
stateMgr.registerHandler(AppState::Options, [&](const SDL_Event& e){ optionsState->handleEvent(e); });
|
|
stateMgr.registerOnEnter(AppState::Options, [&](){ optionsState->onEnter(); });
|
|
stateMgr.registerOnExit(AppState::Options, [&](){ optionsState->onExit(); });
|
|
|
|
stateMgr.registerHandler(AppState::LevelSelector, [&](const SDL_Event& e){ levelSelectorState->handleEvent(e); });
|
|
stateMgr.registerOnEnter(AppState::LevelSelector, [&](){ levelSelectorState->onEnter(); });
|
|
stateMgr.registerOnExit(AppState::LevelSelector, [&](){ levelSelectorState->onExit(); });
|
|
|
|
// Combined Playing state handler: run playingState handler
|
|
stateMgr.registerHandler(AppState::Playing, [&](const SDL_Event& e){
|
|
// First give the PlayingState a chance to handle the event
|
|
playingState->handleEvent(e);
|
|
});
|
|
stateMgr.registerOnEnter(AppState::Playing, [&](){ playingState->onEnter(); });
|
|
stateMgr.registerOnExit(AppState::Playing, [&](){ playingState->onExit(); });
|
|
|
|
// Playing, LevelSelect and GameOver currently use inline logic in main; we'll migrate later
|
|
while (running)
|
|
{
|
|
if (!ctx.scores && scoresLoadComplete.load(std::memory_order_acquire)) {
|
|
ensureScoresLoaded();
|
|
}
|
|
|
|
int winW = 0, winH = 0;
|
|
SDL_GetWindowSize(window, &winW, &winH);
|
|
|
|
// Use the full window for the viewport, scale to fit content
|
|
logicalScale = std::min(winW / (float)LOGICAL_W, winH / (float)LOGICAL_H);
|
|
if (logicalScale <= 0)
|
|
logicalScale = 1.f;
|
|
|
|
// Fill the entire window with our viewport
|
|
logicalVP.w = winW;
|
|
logicalVP.h = winH;
|
|
logicalVP.x = 0;
|
|
logicalVP.y = 0;
|
|
// --- Events ---
|
|
SDL_Event e;
|
|
while (SDL_PollEvent(&e))
|
|
{
|
|
if (e.type == SDL_EVENT_QUIT || e.type == SDL_EVENT_WINDOW_CLOSE_REQUESTED)
|
|
running = false;
|
|
else {
|
|
// Route event to state manager handlers for per-state logic
|
|
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);
|
|
// Keep the local `state` variable in sync with StateManager in case
|
|
// a state handler requested a transition (handlers may call
|
|
// stateMgr.setState()). Many branches below rely on the local
|
|
// `state` variable, so update it immediately after handling.
|
|
state = stateMgr.getState();
|
|
}
|
|
|
|
// Global key toggles (applies regardless of state)
|
|
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_S)
|
|
{
|
|
// Toggle sound effects
|
|
SoundEffectManager::instance().setEnabled(!SoundEffectManager::instance().isEnabled());
|
|
Settings::instance().setSoundEnabled(SoundEffectManager::instance().isEnabled());
|
|
}
|
|
if (e.key.scancode == SDL_SCANCODE_H && state != AppState::Loading)
|
|
{
|
|
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.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);
|
|
}
|
|
}
|
|
|
|
// Text input for high score
|
|
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) {
|
|
// Restart
|
|
game.reset(startLevelSelection);
|
|
state = AppState::Playing;
|
|
stateMgr.setState(state);
|
|
} else if (e.key.scancode == SDL_SCANCODE_ESCAPE) {
|
|
// Menu
|
|
state = AppState::Menu;
|
|
stateMgr.setState(state);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Mouse handling remains in main loop for UI interactions
|
|
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)
|
|
{
|
|
// Compute content offsets (match MenuState centering)
|
|
float contentW = LOGICAL_W * logicalScale;
|
|
float contentH = LOGICAL_H * logicalScale;
|
|
float contentOffsetX = (winW - contentW) * 0.5f / logicalScale;
|
|
float contentOffsetY = (winH - contentH) * 0.5f / logicalScale;
|
|
|
|
if (showSettingsPopup) {
|
|
// Click anywhere closes settings popup
|
|
showSettingsPopup = false;
|
|
} else {
|
|
// Responsive Main menu buttons (match MenuState layout)
|
|
bool isSmall = ((LOGICAL_W * logicalScale) < 700.0f);
|
|
float btnW = isSmall ? (LOGICAL_W * 0.4f) : 300.0f;
|
|
float btnH = isSmall ? 60.0f : 70.0f;
|
|
float btnCX = LOGICAL_W * 0.5f + contentOffsetX;
|
|
const float btnYOffset = 40.0f; // must match MenuState offset
|
|
float btnCY = LOGICAL_H * 0.86f + contentOffsetY + btnYOffset;
|
|
float spacing = isSmall ? btnW * 1.15f : btnW * 1.05f;
|
|
std::array<SDL_FRect, 4> buttonRects{};
|
|
for (int i = 0; i < 4; ++i) {
|
|
float center = btnCX + (static_cast<float>(i) - 1.5f) * spacing;
|
|
buttonRects[i] = SDL_FRect{center - btnW / 2.0f, btnCY - btnH / 2.0f, btnW, btnH};
|
|
}
|
|
|
|
auto pointInRect = [&](const SDL_FRect& r) {
|
|
return lx >= r.x && lx <= r.x + r.w && ly >= r.y && ly <= r.y + r.h;
|
|
};
|
|
|
|
if (pointInRect(buttonRects[0])) {
|
|
startMenuPlayTransition();
|
|
} else if (pointInRect(buttonRects[1])) {
|
|
requestStateFade(AppState::LevelSelector);
|
|
} else if (pointInRect(buttonRects[2])) {
|
|
requestStateFade(AppState::Options);
|
|
} else if (pointInRect(buttonRects[3])) {
|
|
showExitConfirmPopup = true;
|
|
exitPopupSelectedButton = 1;
|
|
}
|
|
|
|
// Settings button (gear icon area - top right)
|
|
SDL_FRect settingsBtn{LOGICAL_W - 60, 10, 50, 30};
|
|
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) {
|
|
// Convert mouse to logical coordinates and to content-local coords
|
|
float lx = (mx - logicalVP.x) / logicalScale;
|
|
float ly = (my - logicalVP.y) / logicalScale;
|
|
// Compute content offsets (same as in render path)
|
|
float contentW = LOGICAL_W * logicalScale;
|
|
float contentH = LOGICAL_H * logicalScale;
|
|
float contentOffsetX = (winW - contentW) * 0.5f / logicalScale;
|
|
float contentOffsetY = (winH - contentH) * 0.5f / logicalScale;
|
|
// Map to content-local logical coords (what drawing code uses)
|
|
float localX = lx - contentOffsetX;
|
|
float localY = ly - contentOffsetY;
|
|
|
|
// Popup rect in logical coordinates (content-local)
|
|
float popupW = 400, popupH = 200;
|
|
float popupX = (LOGICAL_W - popupW) / 2.0f;
|
|
float popupY = (LOGICAL_H - popupH) / 2.0f;
|
|
// Simple Yes/No buttons
|
|
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) {
|
|
// Click inside popup - check buttons
|
|
if (localX >= yesX && localX <= yesX + btnW && localY >= btnY && localY <= btnY + btnH) {
|
|
// Yes -> go back to menu
|
|
showExitConfirmPopup = false;
|
|
game.reset(startLevelSelection);
|
|
state = AppState::Menu;
|
|
stateMgr.setState(state);
|
|
} else if (localX >= noX && localX <= noX + btnW && localY >= btnY && localY <= btnY + btnH) {
|
|
// No -> close popup and resume
|
|
showExitConfirmPopup = false;
|
|
game.setPaused(false);
|
|
}
|
|
} else {
|
|
// Click outside popup: cancel
|
|
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)
|
|
{
|
|
// Compute content offsets and responsive buttons (match MenuState)
|
|
float contentW = LOGICAL_W * logicalScale;
|
|
float contentH = LOGICAL_H * logicalScale;
|
|
float contentOffsetX = (winW - contentW) * 0.5f / logicalScale;
|
|
float contentOffsetY = (winH - contentH) * 0.5f / logicalScale;
|
|
bool isSmall = ((LOGICAL_W * logicalScale) < 700.0f);
|
|
float btnW = isSmall ? (LOGICAL_W * 0.4f) : 300.0f;
|
|
float btnH = isSmall ? 60.0f : 70.0f;
|
|
float btnCX = LOGICAL_W * 0.5f + contentOffsetX;
|
|
const float btnYOffset = 40.0f; // must match MenuState offset
|
|
float btnCY = LOGICAL_H * 0.86f + contentOffsetY + btnYOffset;
|
|
float spacing = isSmall ? btnW * 1.15f : btnW * 1.05f;
|
|
hoveredButton = -1;
|
|
for (int i = 0; i < 4; ++i) {
|
|
float center = btnCX + (static_cast<float>(i) - 1.5f) * spacing;
|
|
SDL_FRect rect{center - btnW / 2.0f, btnCY - btnH / 2.0f, btnW, btnH};
|
|
if (lx >= rect.x && lx <= rect.x + rect.w && ly >= rect.y && ly <= rect.y + rect.h) {
|
|
hoveredButton = i;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Timing ---
|
|
Uint64 now = SDL_GetPerformanceCounter();
|
|
double frameMs = double(now - lastMs) * 1000.0 / double(SDL_GetPerformanceFrequency());
|
|
lastMs = now;
|
|
|
|
// Cap frame time to avoid spiral of death (max 100ms)
|
|
if (frameMs > 100.0) frameMs = 100.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];
|
|
|
|
// Inform game about soft-drop state for scoring parity (1 point per cell when holding Down)
|
|
if (state == AppState::Playing)
|
|
game.setSoftDropping(down && !game.isPaused());
|
|
else
|
|
game.setSoftDropping(false);
|
|
|
|
// Handle DAS/ARR
|
|
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 (state == AppState::Playing)
|
|
{
|
|
if (!game.isPaused()) {
|
|
game.tickGravity(frameMs);
|
|
game.updateElapsedTime();
|
|
|
|
// Update line effect and clear lines when animation completes
|
|
if (lineEffect.isActive()) {
|
|
if (lineEffect.update(frameMs / 1000.0f)) {
|
|
// Effect is complete, now actually clear the lines
|
|
game.clearCompletedLines();
|
|
}
|
|
}
|
|
}
|
|
if (game.isGameOver())
|
|
{
|
|
// Always allow name entry if score > 0
|
|
if (game.score() > 0) {
|
|
isNewHighScore = true; // Reuse flag to trigger input mode
|
|
playerName = "";
|
|
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)
|
|
{
|
|
// Initialize audio system and start background loading on first frame
|
|
if (!musicLoaded && currentTrackLoading == 0) {
|
|
Audio::instance().init();
|
|
// Apply audio settings
|
|
Audio::instance().setMuted(!Settings::instance().isMusicEnabled());
|
|
// Note: SoundEffectManager doesn't have a global mute yet, but we can add it or handle it in playSound
|
|
|
|
// Count actual music files first
|
|
totalTracks = 0;
|
|
std::vector<std::string> trackPaths;
|
|
trackPaths.reserve(100);
|
|
for (int i = 1; i <= 100; ++i) {
|
|
char base[64];
|
|
std::snprintf(base, sizeof(base), "assets/music/music%03d", i);
|
|
std::string path = AssetPath::resolveWithExtensions(base, { ".mp3" });
|
|
if (path.empty()) {
|
|
break;
|
|
}
|
|
trackPaths.push_back(path);
|
|
}
|
|
totalTracks = static_cast<int>(trackPaths.size());
|
|
|
|
for (const auto& track : trackPaths) {
|
|
Audio::instance().addTrackAsync(track);
|
|
}
|
|
|
|
// Start background loading thread
|
|
Audio::instance().startBackgroundLoading();
|
|
currentTrackLoading = 1; // Mark as started
|
|
}
|
|
|
|
// Update progress based on background loading
|
|
if (currentTrackLoading > 0 && !musicLoaded) {
|
|
currentTrackLoading = Audio::instance().getLoadedTrackCount();
|
|
// If loading is complete OR we've loaded all expected tracks (handles potential thread cleanup hang)
|
|
if (Audio::instance().isLoadingComplete() || (totalTracks > 0 && currentTrackLoading >= totalTracks)) {
|
|
Audio::instance().shuffle(); // Shuffle once all tracks are loaded
|
|
musicLoaded = true;
|
|
}
|
|
}
|
|
|
|
// Calculate comprehensive loading progress
|
|
// Phase 1: Initial assets (textures, fonts) - 20%
|
|
double assetProgress = 0.2; // Assets are loaded at startup
|
|
|
|
// Phase 2: Music loading - 70%
|
|
double musicProgress = 0.0;
|
|
if (totalTracks > 0) {
|
|
musicProgress = musicLoaded ? 0.7 : std::min(0.7, (double)currentTrackLoading / totalTracks * 0.7);
|
|
}
|
|
|
|
// Phase 3: Final initialization - 10%
|
|
double timeProgress = std::min(0.1, (now - loadStart) / 500.0); // Faster final phase
|
|
|
|
loadingProgress = assetProgress + musicProgress + timeProgress;
|
|
|
|
// Ensure we never exceed 100% and reach exactly 100% when everything is loaded
|
|
loadingProgress = std::min(1.0, loadingProgress);
|
|
|
|
// Fix floating point precision issues (0.2 + 0.7 + 0.1 can be 0.9999...)
|
|
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)
|
|
{
|
|
// Load menu track once on first menu entry (in background to avoid blocking)
|
|
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;
|
|
}
|
|
|
|
// Start appropriate music based on state
|
|
if (state == AppState::Menu) {
|
|
Audio::instance().playMenuMusic();
|
|
} else {
|
|
Audio::instance().playGameMusic();
|
|
}
|
|
musicStarted = true;
|
|
}
|
|
}
|
|
|
|
// Handle music transitions between states
|
|
static AppState previousState = AppState::Loading;
|
|
if (state != previousState && musicStarted) {
|
|
if (state == AppState::Menu && previousState == AppState::Playing) {
|
|
// Switched from game to menu
|
|
Audio::instance().playMenuMusic();
|
|
} else if (state == AppState::Playing && previousState == AppState::Menu) {
|
|
// Switched from menu to game
|
|
Audio::instance().playGameMusic();
|
|
}
|
|
}
|
|
previousState = state;
|
|
|
|
// Update starfields based on current state
|
|
if (state == AppState::Loading) {
|
|
starfield3D.update(float(frameMs / 1000.0f));
|
|
starfield3D.resize(logicalVP.w, logicalVP.h); // Update for window resize
|
|
} else {
|
|
starfield.update(float(frameMs / 1000.0f), logicalVP.x * 2 + logicalVP.w, logicalVP.y * 2 + logicalVP.h);
|
|
}
|
|
|
|
// Advance level background fade if a next texture is queued
|
|
updateLevelBackgroundFade(levelBackgrounds, float(frameMs));
|
|
|
|
// Update intro animations
|
|
if (state == AppState::Menu) {
|
|
logoAnimCounter += frameMs * 0.0008; // Animation speed
|
|
updateFireworks(frameMs);
|
|
}
|
|
|
|
// --- Per-state update hooks (allow states to manage logic incrementally)
|
|
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 (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) {
|
|
menuPlayCountdownArmed = true;
|
|
gameplayCountdownActive = false;
|
|
gameplayCountdownIndex = 0;
|
|
gameplayCountdownElapsed = 0.0;
|
|
game.setPaused(true);
|
|
} else {
|
|
menuPlayCountdownArmed = false;
|
|
gameplayCountdownActive = false;
|
|
gameplayCountdownIndex = 0;
|
|
gameplayCountdownElapsed = 0.0;
|
|
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) {
|
|
gameplayCountdownActive = true;
|
|
menuPlayCountdownArmed = false;
|
|
gameplayCountdownElapsed = 0.0;
|
|
gameplayCountdownIndex = 0;
|
|
game.setPaused(true);
|
|
}
|
|
|
|
if (gameplayCountdownActive && state == AppState::Playing) {
|
|
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;
|
|
game.setPaused(false);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (state != AppState::Playing && gameplayCountdownActive) {
|
|
gameplayCountdownActive = false;
|
|
menuPlayCountdownArmed = false;
|
|
gameplayCountdownElapsed = 0.0;
|
|
gameplayCountdownIndex = 0;
|
|
game.setPaused(false);
|
|
}
|
|
|
|
// --- Render ---
|
|
SDL_SetRenderViewport(renderer, nullptr);
|
|
SDL_SetRenderDrawColor(renderer, 12, 12, 16, 255);
|
|
SDL_RenderClear(renderer);
|
|
|
|
// Draw level-based background for gameplay, starfield for other states
|
|
if (state == AppState::Playing) {
|
|
int bgLevel = std::clamp(game.level(), 0, 32);
|
|
queueLevelBackground(levelBackgrounds, renderer, bgLevel);
|
|
renderLevelBackgrounds(levelBackgrounds, renderer, winW, winH);
|
|
} else if (state == AppState::Loading) {
|
|
// Use 3D starfield for loading screen (full screen)
|
|
starfield3D.draw(renderer);
|
|
} else if (state == AppState::Menu || state == AppState::LevelSelector || state == AppState::Options) {
|
|
// Use static background for menu, stretched to window; no starfield on sides
|
|
if (backgroundTex) {
|
|
SDL_FRect fullRect = { 0, 0, (float)winW, (float)winH };
|
|
SDL_RenderTexture(renderer, backgroundTex, nullptr, &fullRect);
|
|
}
|
|
} else {
|
|
// Use regular starfield for other states (not gameplay)
|
|
starfield.draw(renderer);
|
|
}
|
|
SDL_SetRenderViewport(renderer, &logicalVP);
|
|
SDL_SetRenderScale(renderer, logicalScale, logicalScale);
|
|
|
|
switch (state)
|
|
{
|
|
case AppState::Loading:
|
|
{
|
|
// Calculate actual content area (centered within the window)
|
|
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)
|
|
{ SDL_SetRenderDrawColor(renderer,c.r,c.g,c.b,c.a); SDL_FRect fr{x + contentOffsetX, y + contentOffsetY, w, h}; SDL_RenderFillRect(renderer,&fr); };
|
|
|
|
// Calculate dimensions for perfect centering (like JavaScript version)
|
|
const bool isLimitedHeight = LOGICAL_H < 450;
|
|
const float logoHeight = logoTex ? (isLimitedHeight ? LOGICAL_H * 0.25f : LOGICAL_H * 0.4f) : 0;
|
|
const float loadingTextHeight = 20; // Height of "LOADING" text (match JS)
|
|
const float barHeight = 20; // Loading bar height (match JS)
|
|
const float barPaddingVertical = isLimitedHeight ? 15 : 35;
|
|
const float percentTextHeight = 24; // Height of percentage text
|
|
const float spacingBetweenElements = isLimitedHeight ? 5 : 15;
|
|
|
|
// Total content height
|
|
const float totalContentHeight = logoHeight +
|
|
(logoHeight > 0 ? spacingBetweenElements : 0) +
|
|
loadingTextHeight +
|
|
barPaddingVertical +
|
|
barHeight +
|
|
spacingBetweenElements +
|
|
percentTextHeight;
|
|
|
|
// Start Y position for perfect vertical centering
|
|
float currentY = (LOGICAL_H - totalContentHeight) / 2.0f;
|
|
|
|
// Draw logo (centered, static like JavaScript version)
|
|
if (logoTex)
|
|
{
|
|
// Use the same original large logo dimensions as JS (we used a half-size BMP previously)
|
|
const int lw = 872, lh = 273;
|
|
|
|
// Cap logo width similar to JS UI.MAX_LOGO_WIDTH (600) and available screen space
|
|
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;
|
|
}
|
|
|
|
// Draw "LOADING" text (centered, using pixel font)
|
|
const char* loadingText = "LOADING";
|
|
float textWidth = strlen(loadingText) * 12.0f; // Approximate width for pixel font
|
|
float textX = (LOGICAL_W - textWidth) / 2.0f;
|
|
pixelFont.draw(renderer, textX + contentOffsetX, currentY + contentOffsetY, loadingText, 1.0f, {255, 204, 0, 255});
|
|
|
|
currentY += loadingTextHeight + barPaddingVertical;
|
|
|
|
// Draw loading bar (like JavaScript version)
|
|
const int barW = 400, barH = 20;
|
|
const int bx = (LOGICAL_W - barW) / 2;
|
|
|
|
// Bar border (dark gray) - using drawRect which adds content offset
|
|
drawRect(bx - 3, currentY - 3, barW + 6, barH + 6, {68, 68, 80, 255});
|
|
|
|
// Bar background (darker gray)
|
|
drawRect(bx, currentY, barW, barH, {34, 34, 34, 255});
|
|
|
|
// Progress bar (gold color)
|
|
drawRect(bx, currentY, int(barW * loadingProgress), barH, {255, 204, 0, 255});
|
|
|
|
currentY += barH + spacingBetweenElements;
|
|
|
|
// Draw percentage text (centered, using pixel font)
|
|
int percentage = int(loadingProgress * 100);
|
|
char percentText[16];
|
|
std::snprintf(percentText, sizeof(percentText), "%d%%", percentage);
|
|
|
|
float percentWidth = strlen(percentText) * 12.0f; // Approximate width for pixel font
|
|
float percentX = (LOGICAL_W - percentWidth) / 2.0f;
|
|
pixelFont.draw(renderer, percentX + contentOffsetX, currentY + contentOffsetY, percentText, 1.5f, {255, 204, 0, 255});
|
|
}
|
|
break;
|
|
case AppState::Menu:
|
|
// Delegate full menu rendering to MenuState object now
|
|
menuState->render(renderer, logicalScale, logicalVP);
|
|
break;
|
|
case AppState::Options:
|
|
optionsState->render(renderer, logicalScale, logicalVP);
|
|
break;
|
|
case AppState::LevelSelector:
|
|
// Delegate level selector rendering to LevelSelectorState
|
|
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:
|
|
// Draw the game state in the background
|
|
GameRenderer::renderPlayingState(
|
|
renderer,
|
|
&game,
|
|
&pixelFont,
|
|
&lineEffect,
|
|
blocksTex,
|
|
(float)LOGICAL_W,
|
|
(float)LOGICAL_H,
|
|
logicalScale,
|
|
(float)winW,
|
|
(float)winH
|
|
);
|
|
|
|
// Draw Game Over Overlay
|
|
{
|
|
// 1. Dim the background
|
|
SDL_SetRenderViewport(renderer, nullptr); // Use window coordinates for full screen dim
|
|
SDL_SetRenderDrawColor(renderer, 0, 0, 0, 180); // Dark semi-transparent
|
|
SDL_FRect fullWin{0.f, 0.f, (float)winW, (float)winH};
|
|
SDL_RenderFillRect(renderer, &fullWin);
|
|
|
|
// Restore logical viewport
|
|
SDL_SetRenderViewport(renderer, &logicalVP);
|
|
SDL_SetRenderScale(renderer, logicalScale, logicalScale);
|
|
|
|
// 2. Calculate content offsets (same as in GameRenderer)
|
|
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;
|
|
|
|
// 3. Draw Game Over Box
|
|
float boxW = 500.0f;
|
|
float boxH = 350.0f;
|
|
float boxX = (LOGICAL_W - boxW) * 0.5f;
|
|
float boxY = (LOGICAL_H - boxH) * 0.5f;
|
|
|
|
// Draw box background
|
|
SDL_SetRenderDrawColor(renderer, 20, 25, 35, 255);
|
|
SDL_FRect boxRect{boxX + contentOffsetX, boxY + contentOffsetY, boxW, boxH};
|
|
SDL_RenderFillRect(renderer, &boxRect);
|
|
|
|
// Draw box border
|
|
SDL_SetRenderDrawColor(renderer, 100, 120, 200, 255);
|
|
SDL_FRect borderRect{boxX + contentOffsetX - 3, boxY + contentOffsetY - 3, boxW + 6, boxH + 6};
|
|
SDL_RenderFillRect(renderer, &borderRect); // Use FillRect for border background effect
|
|
SDL_SetRenderDrawColor(renderer, 20, 25, 35, 255);
|
|
SDL_RenderFillRect(renderer, &boxRect); // Redraw background on top of border rect
|
|
|
|
// 4. Draw Text
|
|
// 4. Draw Text
|
|
// Title
|
|
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});
|
|
|
|
// Score
|
|
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) {
|
|
// Name Entry
|
|
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});
|
|
|
|
// Input box
|
|
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);
|
|
|
|
// Player Name (blink cursor without shifting text)
|
|
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; // fallback height
|
|
|
|
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});
|
|
}
|
|
|
|
// Hint
|
|
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 {
|
|
// Lines
|
|
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});
|
|
|
|
// Level
|
|
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});
|
|
|
|
// Instructions
|
|
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) {
|
|
// Switch to window coordinates for perfect centering in any resolution
|
|
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; // Much bigger fonts for countdown
|
|
int textW = 0, textH = 0;
|
|
pixelFont.measure(label, textScale, textW, textH);
|
|
|
|
// Center in actual window coordinates (works for any resolution/fullscreen)
|
|
float textX = (winW - static_cast<float>(textW)) * 0.5f;
|
|
float textY = (winH - static_cast<float>(textH)) * 0.5f;
|
|
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);
|
|
}
|
|
if (logoTex)
|
|
SDL_DestroyTexture(logoTex);
|
|
if (backgroundTex)
|
|
SDL_DestroyTexture(backgroundTex);
|
|
resetLevelBackgrounds(levelBackgrounds);
|
|
if (blocksTex)
|
|
SDL_DestroyTexture(blocksTex);
|
|
if (logoSmallTex)
|
|
SDL_DestroyTexture(logoSmallTex);
|
|
|
|
// Save settings on exit
|
|
Settings::instance().save();
|
|
|
|
if (scoreLoader.joinable()) {
|
|
scoreLoader.join();
|
|
if (!ctx.scores) {
|
|
ctx.scores = &scores;
|
|
}
|
|
}
|
|
if (menuTrackLoader.joinable()) {
|
|
menuTrackLoader.join();
|
|
}
|
|
lineEffect.shutdown();
|
|
Audio::instance().shutdown();
|
|
SoundEffectManager::instance().shutdown();
|
|
font.shutdown();
|
|
TTF_Quit();
|
|
SDL_DestroyRenderer(renderer);
|
|
SDL_DestroyWindow(window);
|
|
SDL_Quit();
|
|
return 0;
|
|
}
|