Added intro video
This commit is contained in:
@ -59,6 +59,7 @@ set(TETRIS_SOURCES
|
|||||||
src/audio/Audio.cpp
|
src/audio/Audio.cpp
|
||||||
src/gameplay/effects/LineEffect.cpp
|
src/gameplay/effects/LineEffect.cpp
|
||||||
src/audio/SoundEffect.cpp
|
src/audio/SoundEffect.cpp
|
||||||
|
src/video/VideoPlayer.cpp
|
||||||
src/ui/MenuLayout.cpp
|
src/ui/MenuLayout.cpp
|
||||||
src/ui/BottomMenu.cpp
|
src/ui/BottomMenu.cpp
|
||||||
src/app/BackgroundManager.cpp
|
src/app/BackgroundManager.cpp
|
||||||
@ -68,6 +69,7 @@ set(TETRIS_SOURCES
|
|||||||
src/states/LoadingManager.cpp
|
src/states/LoadingManager.cpp
|
||||||
# State implementations (new)
|
# State implementations (new)
|
||||||
src/states/LoadingState.cpp
|
src/states/LoadingState.cpp
|
||||||
|
src/states/VideoState.cpp
|
||||||
src/states/MenuState.cpp
|
src/states/MenuState.cpp
|
||||||
src/states/OptionsState.cpp
|
src/states/OptionsState.cpp
|
||||||
src/states/LevelSelectorState.cpp
|
src/states/LevelSelectorState.cpp
|
||||||
@ -164,6 +166,13 @@ endif()
|
|||||||
|
|
||||||
target_link_libraries(spacetris PRIVATE SDL3::SDL3 SDL3_ttf::SDL3_ttf SDL3_image::SDL3_image cpr::cpr nlohmann_json::nlohmann_json unofficial::enet::enet)
|
target_link_libraries(spacetris PRIVATE SDL3::SDL3 SDL3_ttf::SDL3_ttf SDL3_image::SDL3_image cpr::cpr nlohmann_json::nlohmann_json unofficial::enet::enet)
|
||||||
|
|
||||||
|
find_package(FFMPEG REQUIRED)
|
||||||
|
if(FFMPEG_FOUND)
|
||||||
|
target_include_directories(spacetris PRIVATE ${FFMPEG_INCLUDE_DIRS})
|
||||||
|
target_link_directories(spacetris PRIVATE ${FFMPEG_LIBRARY_DIRS})
|
||||||
|
target_link_libraries(spacetris PRIVATE ${FFMPEG_LIBRARIES})
|
||||||
|
endif()
|
||||||
|
|
||||||
if (WIN32)
|
if (WIN32)
|
||||||
target_link_libraries(spacetris PRIVATE mfplat mfreadwrite mfuuid ws2_32 winmm)
|
target_link_libraries(spacetris PRIVATE mfplat mfreadwrite mfuuid ws2_32 winmm)
|
||||||
endif()
|
endif()
|
||||||
@ -196,6 +205,7 @@ endif()
|
|||||||
target_include_directories(spacetris PRIVATE
|
target_include_directories(spacetris PRIVATE
|
||||||
${CMAKE_SOURCE_DIR}/src
|
${CMAKE_SOURCE_DIR}/src
|
||||||
${CMAKE_SOURCE_DIR}/src/audio
|
${CMAKE_SOURCE_DIR}/src/audio
|
||||||
|
${CMAKE_SOURCE_DIR}/src/video
|
||||||
${CMAKE_SOURCE_DIR}/src/gameplay
|
${CMAKE_SOURCE_DIR}/src/gameplay
|
||||||
${CMAKE_SOURCE_DIR}/src/graphics
|
${CMAKE_SOURCE_DIR}/src/graphics
|
||||||
${CMAKE_SOURCE_DIR}/src/persistence
|
${CMAKE_SOURCE_DIR}/src/persistence
|
||||||
|
|||||||
BIN
assets/videos/spacetris_intro.mp4
Normal file
BIN
assets/videos/spacetris_intro.mp4
Normal file
Binary file not shown.
0
scripts/check_braces.ps1
Normal file
0
scripts/check_braces.ps1
Normal file
0
scripts/check_comments.ps1
Normal file
0
scripts/check_comments.ps1
Normal file
0
scripts/find_unmatched.ps1
Normal file
0
scripts/find_unmatched.ps1
Normal file
@ -60,6 +60,7 @@
|
|||||||
#include "states/MenuState.h"
|
#include "states/MenuState.h"
|
||||||
#include "states/OptionsState.h"
|
#include "states/OptionsState.h"
|
||||||
#include "states/PlayingState.h"
|
#include "states/PlayingState.h"
|
||||||
|
#include "states/VideoState.h"
|
||||||
#include "states/State.h"
|
#include "states/State.h"
|
||||||
|
|
||||||
#include "ui/BottomMenu.h"
|
#include "ui/BottomMenu.h"
|
||||||
@ -310,11 +311,21 @@ struct TetrisApp::Impl {
|
|||||||
std::unique_ptr<StateManager> stateMgr;
|
std::unique_ptr<StateManager> stateMgr;
|
||||||
StateContext ctx{};
|
StateContext ctx{};
|
||||||
std::unique_ptr<LoadingState> loadingState;
|
std::unique_ptr<LoadingState> loadingState;
|
||||||
|
std::unique_ptr<VideoState> videoState;
|
||||||
std::unique_ptr<MenuState> menuState;
|
std::unique_ptr<MenuState> menuState;
|
||||||
std::unique_ptr<OptionsState> optionsState;
|
std::unique_ptr<OptionsState> optionsState;
|
||||||
std::unique_ptr<LevelSelectorState> levelSelectorState;
|
std::unique_ptr<LevelSelectorState> levelSelectorState;
|
||||||
std::unique_ptr<PlayingState> playingState;
|
std::unique_ptr<PlayingState> playingState;
|
||||||
|
|
||||||
|
// Startup fade-in overlay (used after intro video).
|
||||||
|
bool startupFadeActive = false;
|
||||||
|
float startupFadeAlpha = 0.0f; // 0..1 black overlay strength
|
||||||
|
double startupFadeClockMs = 0.0;
|
||||||
|
static constexpr double STARTUP_FADE_IN_MS = 650.0;
|
||||||
|
|
||||||
|
// Intro video path.
|
||||||
|
std::string introVideoPath = "assets/videos/spacetris_intro.mp4";
|
||||||
|
|
||||||
int init();
|
int init();
|
||||||
void runLoop();
|
void runLoop();
|
||||||
void shutdown();
|
void shutdown();
|
||||||
@ -671,7 +682,11 @@ int TetrisApp::Impl::init()
|
|||||||
};
|
};
|
||||||
ctx.requestFadeTransition = requestStateFade;
|
ctx.requestFadeTransition = requestStateFade;
|
||||||
|
|
||||||
|
ctx.startupFadeActive = &startupFadeActive;
|
||||||
|
ctx.startupFadeAlpha = &startupFadeAlpha;
|
||||||
|
|
||||||
loadingState = std::make_unique<LoadingState>(ctx);
|
loadingState = std::make_unique<LoadingState>(ctx);
|
||||||
|
videoState = std::make_unique<VideoState>(ctx);
|
||||||
menuState = std::make_unique<MenuState>(ctx);
|
menuState = std::make_unique<MenuState>(ctx);
|
||||||
optionsState = std::make_unique<OptionsState>(ctx);
|
optionsState = std::make_unique<OptionsState>(ctx);
|
||||||
levelSelectorState = std::make_unique<LevelSelectorState>(ctx);
|
levelSelectorState = std::make_unique<LevelSelectorState>(ctx);
|
||||||
@ -681,6 +696,20 @@ int TetrisApp::Impl::init()
|
|||||||
stateMgr->registerOnEnter(AppState::Loading, [this](){ loadingState->onEnter(); loadingStarted.store(true); });
|
stateMgr->registerOnEnter(AppState::Loading, [this](){ loadingState->onEnter(); loadingStarted.store(true); });
|
||||||
stateMgr->registerOnExit(AppState::Loading, [this](){ loadingState->onExit(); });
|
stateMgr->registerOnExit(AppState::Loading, [this](){ loadingState->onExit(); });
|
||||||
|
|
||||||
|
stateMgr->registerHandler(AppState::Video, [this](const SDL_Event& e){ if (videoState) videoState->handleEvent(e); });
|
||||||
|
stateMgr->registerOnEnter(AppState::Video, [this]() {
|
||||||
|
if (!videoState) return;
|
||||||
|
const bool ok = videoState->begin(renderer, introVideoPath);
|
||||||
|
if (!ok) {
|
||||||
|
SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "Intro video unavailable; skipping to Menu");
|
||||||
|
state = AppState::Menu;
|
||||||
|
stateMgr->setState(state);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
videoState->onEnter();
|
||||||
|
});
|
||||||
|
stateMgr->registerOnExit(AppState::Video, [this](){ if (videoState) videoState->onExit(); });
|
||||||
|
|
||||||
stateMgr->registerHandler(AppState::Menu, [this](const SDL_Event& e){ menuState->handleEvent(e); });
|
stateMgr->registerHandler(AppState::Menu, [this](const SDL_Event& e){ menuState->handleEvent(e); });
|
||||||
stateMgr->registerOnEnter(AppState::Menu, [this](){ menuState->onEnter(); });
|
stateMgr->registerOnEnter(AppState::Menu, [this](){ menuState->onEnter(); });
|
||||||
stateMgr->registerOnExit(AppState::Menu, [this](){ menuState->onExit(); });
|
stateMgr->registerOnExit(AppState::Menu, [this](){ menuState->onExit(); });
|
||||||
@ -832,7 +861,7 @@ void TetrisApp::Impl::runLoop()
|
|||||||
Settings::instance().setSoundEnabled(SoundEffectManager::instance().isEnabled());
|
Settings::instance().setSoundEnabled(SoundEffectManager::instance().isEnabled());
|
||||||
}
|
}
|
||||||
const bool helpToggleKey =
|
const bool helpToggleKey =
|
||||||
(e.key.scancode == SDL_SCANCODE_F1 && state != AppState::Loading && state != AppState::Menu);
|
(e.key.scancode == SDL_SCANCODE_F1 && state != AppState::Loading && state != AppState::Video && state != AppState::Menu);
|
||||||
if (helpToggleKey)
|
if (helpToggleKey)
|
||||||
{
|
{
|
||||||
showHelpOverlay = !showHelpOverlay;
|
showHelpOverlay = !showHelpOverlay;
|
||||||
@ -1168,6 +1197,21 @@ void TetrisApp::Impl::runLoop()
|
|||||||
if (frameMs > 100.0) frameMs = 100.0;
|
if (frameMs > 100.0) frameMs = 100.0;
|
||||||
gameplayBackgroundClockMs += frameMs;
|
gameplayBackgroundClockMs += frameMs;
|
||||||
|
|
||||||
|
if (startupFadeActive) {
|
||||||
|
if (startupFadeClockMs <= 0.0) {
|
||||||
|
startupFadeClockMs = STARTUP_FADE_IN_MS;
|
||||||
|
startupFadeAlpha = 1.0f;
|
||||||
|
}
|
||||||
|
startupFadeClockMs -= frameMs;
|
||||||
|
if (startupFadeClockMs <= 0.0) {
|
||||||
|
startupFadeClockMs = 0.0;
|
||||||
|
startupFadeAlpha = 0.0f;
|
||||||
|
startupFadeActive = false;
|
||||||
|
} else {
|
||||||
|
startupFadeAlpha = float(std::clamp(startupFadeClockMs / STARTUP_FADE_IN_MS, 0.0, 1.0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
auto clearChallengeStory = [this]() {
|
auto clearChallengeStory = [this]() {
|
||||||
challengeStoryText.clear();
|
challengeStoryText.clear();
|
||||||
challengeStoryLevel = 0;
|
challengeStoryLevel = 0;
|
||||||
@ -1810,7 +1854,15 @@ void TetrisApp::Impl::runLoop()
|
|||||||
if (totalTasks > 0) {
|
if (totalTasks > 0) {
|
||||||
loadingProgress = std::min(1.0, double(doneTasks) / double(totalTasks));
|
loadingProgress = std::min(1.0, double(doneTasks) / double(totalTasks));
|
||||||
if (loadingProgress >= 1.0 && musicLoaded) {
|
if (loadingProgress >= 1.0 && musicLoaded) {
|
||||||
state = AppState::Menu;
|
startupFadeActive = false;
|
||||||
|
startupFadeAlpha = 0.0f;
|
||||||
|
startupFadeClockMs = 0.0;
|
||||||
|
|
||||||
|
if (std::filesystem::exists(introVideoPath)) {
|
||||||
|
state = AppState::Video;
|
||||||
|
} else {
|
||||||
|
state = AppState::Menu;
|
||||||
|
}
|
||||||
stateMgr->setState(state);
|
stateMgr->setState(state);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@ -1838,7 +1890,15 @@ void TetrisApp::Impl::runLoop()
|
|||||||
if (loadingProgress > 0.99) loadingProgress = 1.0;
|
if (loadingProgress > 0.99) loadingProgress = 1.0;
|
||||||
if (!musicLoaded && timeProgress >= 0.1) loadingProgress = 1.0;
|
if (!musicLoaded && timeProgress >= 0.1) loadingProgress = 1.0;
|
||||||
if (loadingProgress >= 1.0 && musicLoaded) {
|
if (loadingProgress >= 1.0 && musicLoaded) {
|
||||||
state = AppState::Menu;
|
startupFadeActive = false;
|
||||||
|
startupFadeAlpha = 0.0f;
|
||||||
|
startupFadeClockMs = 0.0;
|
||||||
|
|
||||||
|
if (std::filesystem::exists(introVideoPath)) {
|
||||||
|
state = AppState::Video;
|
||||||
|
} else {
|
||||||
|
state = AppState::Menu;
|
||||||
|
}
|
||||||
stateMgr->setState(state);
|
stateMgr->setState(state);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1905,6 +1965,9 @@ void TetrisApp::Impl::runLoop()
|
|||||||
case AppState::Loading:
|
case AppState::Loading:
|
||||||
loadingState->update(frameMs);
|
loadingState->update(frameMs);
|
||||||
break;
|
break;
|
||||||
|
case AppState::Video:
|
||||||
|
if (videoState) videoState->update(frameMs);
|
||||||
|
break;
|
||||||
case AppState::Menu:
|
case AppState::Menu:
|
||||||
menuState->update(frameMs);
|
menuState->update(frameMs);
|
||||||
break;
|
break;
|
||||||
@ -2207,6 +2270,11 @@ void TetrisApp::Impl::runLoop()
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
case AppState::Video:
|
||||||
|
if (videoState) {
|
||||||
|
videoState->render(renderer, logicalScale, logicalVP);
|
||||||
|
}
|
||||||
|
break;
|
||||||
case AppState::Menu:
|
case AppState::Menu:
|
||||||
if (!mainScreenTex) {
|
if (!mainScreenTex) {
|
||||||
mainScreenTex = assetLoader.getTexture(Assets::MAIN_SCREEN);
|
mainScreenTex = assetLoader.getTexture(Assets::MAIN_SCREEN);
|
||||||
@ -2600,6 +2668,17 @@ void TetrisApp::Impl::runLoop()
|
|||||||
HelpOverlay::Render(renderer, pixelFont, LOGICAL_W, LOGICAL_H, contentOffsetX, contentOffsetY);
|
HelpOverlay::Render(renderer, pixelFont, LOGICAL_W, LOGICAL_H, contentOffsetX, contentOffsetY);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (startupFadeActive && startupFadeAlpha > 0.0f) {
|
||||||
|
SDL_SetRenderViewport(renderer, nullptr);
|
||||||
|
SDL_SetRenderScale(renderer, 1.0f, 1.0f);
|
||||||
|
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);
|
||||||
|
const Uint8 a = (Uint8)std::clamp((int)std::lround(startupFadeAlpha * 255.0f), 0, 255);
|
||||||
|
SDL_SetRenderDrawColor(renderer, 0, 0, 0, a);
|
||||||
|
SDL_FRect full{0.f, 0.f, (float)winW, (float)winH};
|
||||||
|
SDL_RenderFillRect(renderer, &full);
|
||||||
|
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_NONE);
|
||||||
|
}
|
||||||
|
|
||||||
SDL_RenderPresent(renderer);
|
SDL_RenderPresent(renderer);
|
||||||
SDL_SetRenderScale(renderer, 1.f, 1.f);
|
SDL_SetRenderScale(renderer, 1.f, 1.f);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -32,9 +32,19 @@
|
|||||||
#include <SDL3_ttf/SDL_ttf.h>
|
#include <SDL3_ttf/SDL_ttf.h>
|
||||||
#include "../../utils/ImagePathResolver.h"
|
#include "../../utils/ImagePathResolver.h"
|
||||||
#include <iostream>
|
#include <iostream>
|
||||||
|
#include "../../video/VideoPlayer.h"
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
#include <fstream>
|
#include <fstream>
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
|
#ifdef _WIN32
|
||||||
|
#define WIN32_LEAN_AND_MEAN
|
||||||
|
#ifndef NOMINMAX
|
||||||
|
#define NOMINMAX
|
||||||
|
#endif
|
||||||
|
#include <windows.h>
|
||||||
|
#include <shellapi.h>
|
||||||
|
#endif
|
||||||
|
// (Intro video playback is now handled in-process via VideoPlayer)
|
||||||
|
|
||||||
ApplicationManager::ApplicationManager() = default;
|
ApplicationManager::ApplicationManager() = default;
|
||||||
|
|
||||||
@ -55,7 +65,15 @@ void ApplicationManager::renderLoading(ApplicationManager* app, RenderManager& r
|
|||||||
if (winW_actual > 0 && winH_actual > 0) app->m_starfield3D->resize(winW_actual, winH_actual);
|
if (winW_actual > 0 && winH_actual > 0) app->m_starfield3D->resize(winW_actual, winH_actual);
|
||||||
app->m_starfield3D->draw(renderer.getSDLRenderer());
|
app->m_starfield3D->draw(renderer.getSDLRenderer());
|
||||||
}
|
}
|
||||||
|
// If intro video is playing, render it instead of the loading UI
|
||||||
|
if (app->m_introStarted && app->m_videoPlayer) {
|
||||||
|
SDL_Renderer* sdlR = renderer.getSDLRenderer();
|
||||||
|
int winW=0, winH=0; renderer.getWindowSize(winW, winH);
|
||||||
|
app->m_videoPlayer->render(sdlR, winW, winH);
|
||||||
|
SDL_SetRenderViewport(renderer.getSDLRenderer(), nullptr);
|
||||||
|
SDL_SetRenderScale(renderer.getSDLRenderer(), 1.0f, 1.0f);
|
||||||
|
return;
|
||||||
|
}
|
||||||
SDL_Rect logicalVP = {0,0,0,0};
|
SDL_Rect logicalVP = {0,0,0,0};
|
||||||
float logicalScale = 1.0f;
|
float logicalScale = 1.0f;
|
||||||
if (app->m_renderManager) {
|
if (app->m_renderManager) {
|
||||||
@ -780,17 +798,44 @@ void ApplicationManager::setupStateHandlers() {
|
|||||||
m_starfield3D->update(deltaTime / 1000.0f);
|
m_starfield3D->update(deltaTime / 1000.0f);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if loading is complete and transition to menu
|
// Check if loading is complete and transition to next stage
|
||||||
if (m_assetManager->isLoadingComplete()) {
|
if (m_assetManager->isLoadingComplete()) {
|
||||||
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Loading complete, transitioning to Menu");
|
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Loading complete, handling post-load flow");
|
||||||
|
|
||||||
// Update texture pointers now that assets are loaded
|
// Update texture pointers now that assets are loaded
|
||||||
m_stateContext.backgroundTex = m_assetManager->getTexture("background");
|
m_stateContext.backgroundTex = m_assetManager->getTexture("background");
|
||||||
m_stateContext.blocksTex = m_assetManager->getTexture("blocks");
|
m_stateContext.blocksTex = m_assetManager->getTexture("blocks");
|
||||||
|
|
||||||
bool ok = m_stateManager->setState(AppState::Menu);
|
// If an intro video exists and hasn't been started, attempt to play it in-process
|
||||||
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "setState(AppState::Menu) returned %d", ok ? 1 : 0);
|
std::filesystem::path introPath = m_introPath;
|
||||||
traceFile("- to Menu returned");
|
if (!m_introStarted && std::filesystem::exists(introPath)) {
|
||||||
|
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Intro video found: %s", introPath.string().c_str());
|
||||||
|
try {
|
||||||
|
if (!m_videoPlayer) m_videoPlayer = std::make_unique<VideoPlayer>();
|
||||||
|
SDL_Renderer* sdlRend = (m_renderManager) ? m_renderManager->getSDLRenderer() : nullptr;
|
||||||
|
if (m_videoPlayer->open(introPath.string(), sdlRend)) {
|
||||||
|
m_introStarted = true;
|
||||||
|
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Intro video started in-process");
|
||||||
|
} else {
|
||||||
|
SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "VideoPlayer failed to open intro; skipping");
|
||||||
|
m_stateManager->setState(AppState::Playing);
|
||||||
|
}
|
||||||
|
} catch (const std::exception& ex) {
|
||||||
|
SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "Exception while starting VideoPlayer: %s", ex.what());
|
||||||
|
m_stateManager->setState(AppState::Playing);
|
||||||
|
}
|
||||||
|
} else if (m_introStarted) {
|
||||||
|
// Let VideoPlayer decode frames; once finished, transition to playing
|
||||||
|
if (m_videoPlayer) m_videoPlayer->update();
|
||||||
|
if (!m_videoPlayer || m_videoPlayer->isFinished()) {
|
||||||
|
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Intro video finished (in-process), transitioning to Playing");
|
||||||
|
m_stateManager->setState(AppState::Playing);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No intro to play; transition directly to Playing
|
||||||
|
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "No intro video; transitioning to Playing");
|
||||||
|
m_stateManager->setState(AppState::Playing);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -153,6 +153,11 @@ private:
|
|||||||
float m_logoAnimCounter = 0.0f;
|
float m_logoAnimCounter = 0.0f;
|
||||||
bool m_helpOverlayPausedGame = false;
|
bool m_helpOverlayPausedGame = false;
|
||||||
|
|
||||||
|
// Intro video playback (in-process via FFmpeg)
|
||||||
|
bool m_introStarted = false;
|
||||||
|
std::string m_introPath = "assets/videos/spacetris_intro.mp4";
|
||||||
|
std::unique_ptr<class VideoPlayer> m_videoPlayer;
|
||||||
|
|
||||||
// Gameplay background (per-level) with fade, mirroring main.cpp behavior
|
// Gameplay background (per-level) with fade, mirroring main.cpp behavior
|
||||||
SDL_Texture* m_levelBackgroundTex = nullptr;
|
SDL_Texture* m_levelBackgroundTex = nullptr;
|
||||||
SDL_Texture* m_nextLevelBackgroundTex = nullptr; // used during fade transitions
|
SDL_Texture* m_nextLevelBackgroundTex = nullptr; // used during fade transitions
|
||||||
|
|||||||
@ -156,9 +156,19 @@ void StateManager::render(RenderManager& renderer) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool StateManager::isValidState(AppState state) const {
|
bool StateManager::isValidState(AppState state) const {
|
||||||
// All enum values are currently valid
|
switch (state) {
|
||||||
return static_cast<int>(state) >= static_cast<int>(AppState::Loading) &&
|
case AppState::Loading:
|
||||||
static_cast<int>(state) <= static_cast<int>(AppState::GameOver);
|
case AppState::Video:
|
||||||
|
case AppState::Menu:
|
||||||
|
case AppState::Options:
|
||||||
|
case AppState::LevelSelector:
|
||||||
|
case AppState::Playing:
|
||||||
|
case AppState::LevelSelect:
|
||||||
|
case AppState::GameOver:
|
||||||
|
return true;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
bool StateManager::canTransitionTo(AppState newState) const {
|
bool StateManager::canTransitionTo(AppState newState) const {
|
||||||
@ -169,6 +179,7 @@ bool StateManager::canTransitionTo(AppState newState) const {
|
|||||||
const char* StateManager::getStateName(AppState state) const {
|
const char* StateManager::getStateName(AppState state) const {
|
||||||
switch (state) {
|
switch (state) {
|
||||||
case AppState::Loading: return "Loading";
|
case AppState::Loading: return "Loading";
|
||||||
|
case AppState::Video: return "Video";
|
||||||
case AppState::Menu: return "Menu";
|
case AppState::Menu: return "Menu";
|
||||||
case AppState::Options: return "Options";
|
case AppState::Options: return "Options";
|
||||||
case AppState::LevelSelector: return "LevelSelector";
|
case AppState::LevelSelector: return "LevelSelector";
|
||||||
|
|||||||
@ -12,6 +12,7 @@ class RenderManager;
|
|||||||
// Application states used across the app
|
// Application states used across the app
|
||||||
enum class AppState {
|
enum class AppState {
|
||||||
Loading,
|
Loading,
|
||||||
|
Video,
|
||||||
Menu,
|
Menu,
|
||||||
Options,
|
Options,
|
||||||
LevelSelector,
|
LevelSelector,
|
||||||
|
|||||||
@ -104,6 +104,11 @@ struct StateContext {
|
|||||||
std::function<void()> requestQuit; // Allows menu/option states to close the app gracefully
|
std::function<void()> requestQuit; // Allows menu/option states to close the app gracefully
|
||||||
std::function<void()> startPlayTransition; // Optional fade hook when transitioning from menu to gameplay
|
std::function<void()> startPlayTransition; // Optional fade hook when transitioning from menu to gameplay
|
||||||
std::function<void(AppState)> requestFadeTransition; // Generic state fade requests (menu/options/level)
|
std::function<void(AppState)> requestFadeTransition; // Generic state fade requests (menu/options/level)
|
||||||
|
|
||||||
|
// Startup transition fade (used for intro video -> main).
|
||||||
|
// When active, the app should render a black overlay with alpha = startupFadeAlpha*255.
|
||||||
|
bool* startupFadeActive = nullptr;
|
||||||
|
float* startupFadeAlpha = nullptr;
|
||||||
// Pointer to the application's StateManager so states can request transitions
|
// Pointer to the application's StateManager so states can request transitions
|
||||||
StateManager* stateManager = nullptr;
|
StateManager* stateManager = nullptr;
|
||||||
// Optional explicit per-button coordinates (logical coordinates). When
|
// Optional explicit per-button coordinates (logical coordinates). When
|
||||||
|
|||||||
389
src/states/VideoState.cpp
Normal file
389
src/states/VideoState.cpp
Normal file
@ -0,0 +1,389 @@
|
|||||||
|
// VideoState.cpp
|
||||||
|
#include "VideoState.h"
|
||||||
|
|
||||||
|
#include "../video/VideoPlayer.h"
|
||||||
|
#include "../audio/Audio.h"
|
||||||
|
#include "../core/state/StateManager.h"
|
||||||
|
|
||||||
|
#include <SDL3/SDL.h>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
|
#include <cstring>
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
extern "C" {
|
||||||
|
#include <libavformat/avformat.h>
|
||||||
|
#include <libavcodec/avcodec.h>
|
||||||
|
#include <libavutil/avutil.h>
|
||||||
|
#include <libavutil/channel_layout.h>
|
||||||
|
#include <libswresample/swresample.h>
|
||||||
|
}
|
||||||
|
|
||||||
|
VideoState::VideoState(StateContext& ctx)
|
||||||
|
: State(ctx)
|
||||||
|
, m_player(std::make_unique<VideoPlayer>())
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
VideoState::~VideoState() {
|
||||||
|
onExit();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool VideoState::begin(SDL_Renderer* renderer, const std::string& path) {
|
||||||
|
m_path = path;
|
||||||
|
|
||||||
|
if (!m_player) {
|
||||||
|
m_player = std::make_unique<VideoPlayer>();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!m_player->open(m_path, renderer)) {
|
||||||
|
SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "[VideoState] Failed to open intro video: %s", m_path.c_str());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!m_player->decodeFirstFrame()) {
|
||||||
|
SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "[VideoState] Failed to decode first frame: %s", m_path.c_str());
|
||||||
|
// Still allow entering; we will likely render black.
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void VideoState::onEnter() {
|
||||||
|
m_phase = Phase::FadeInFirstFrame;
|
||||||
|
m_phaseClockMs = 0.0;
|
||||||
|
m_blackOverlayAlpha = 1.0f;
|
||||||
|
|
||||||
|
m_audioDecoded.store(false);
|
||||||
|
m_audioDecodeFailed.store(false);
|
||||||
|
m_audioStarted = false;
|
||||||
|
m_audioPcm.clear();
|
||||||
|
m_audioRate = 44100;
|
||||||
|
m_audioChannels = 2;
|
||||||
|
|
||||||
|
// Decode audio in the background during fade-in.
|
||||||
|
m_audioThread = std::make_unique<std::jthread>([this](std::stop_token st) {
|
||||||
|
(void)st;
|
||||||
|
std::vector<int16_t> pcm;
|
||||||
|
int rate = 44100;
|
||||||
|
int channels = 2;
|
||||||
|
|
||||||
|
const bool ok = decodeAudioPcm16Stereo44100(m_path, pcm, rate, channels);
|
||||||
|
if (!ok) {
|
||||||
|
m_audioDecodeFailed.store(true);
|
||||||
|
m_audioDecoded.store(true, std::memory_order_release);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transfer results.
|
||||||
|
m_audioRate = rate;
|
||||||
|
m_audioChannels = channels;
|
||||||
|
m_audioPcm = std::move(pcm);
|
||||||
|
m_audioDecoded.store(true, std::memory_order_release);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void VideoState::onExit() {
|
||||||
|
stopAudio();
|
||||||
|
|
||||||
|
if (m_audioThread) {
|
||||||
|
// Request stop and join.
|
||||||
|
m_audioThread.reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void VideoState::handleEvent(const SDL_Event& e) {
|
||||||
|
(void)e;
|
||||||
|
}
|
||||||
|
|
||||||
|
void VideoState::startAudioIfReady() {
|
||||||
|
if (m_audioStarted) return;
|
||||||
|
if (!m_audioDecoded.load(std::memory_order_acquire)) return;
|
||||||
|
if (m_audioDecodeFailed.load()) return;
|
||||||
|
if (m_audioPcm.empty()) return;
|
||||||
|
|
||||||
|
// Use the existing audio output path (same device as music/SFX).
|
||||||
|
Audio::instance().playSfx(m_audioPcm, m_audioChannels, m_audioRate, 1.0f);
|
||||||
|
m_audioStarted = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void VideoState::stopAudio() {
|
||||||
|
// We currently feed intro audio as an SFX buffer into the mixer.
|
||||||
|
// It will naturally end; no explicit stop is required.
|
||||||
|
}
|
||||||
|
|
||||||
|
void VideoState::update(double frameMs) {
|
||||||
|
switch (m_phase) {
|
||||||
|
case Phase::FadeInFirstFrame: {
|
||||||
|
m_phaseClockMs += frameMs;
|
||||||
|
const float t = (FADE_IN_MS > 0.0) ? float(std::clamp(m_phaseClockMs / FADE_IN_MS, 0.0, 1.0)) : 1.0f;
|
||||||
|
m_blackOverlayAlpha = 1.0f - t;
|
||||||
|
|
||||||
|
if (t >= 1.0f) {
|
||||||
|
m_phase = Phase::Playing;
|
||||||
|
m_phaseClockMs = 0.0;
|
||||||
|
if (m_player) {
|
||||||
|
m_player->start();
|
||||||
|
}
|
||||||
|
startAudioIfReady();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case Phase::Playing: {
|
||||||
|
startAudioIfReady();
|
||||||
|
if (m_player) {
|
||||||
|
m_player->update(frameMs);
|
||||||
|
if (m_player->isFinished()) {
|
||||||
|
m_phase = Phase::FadeOutToBlack;
|
||||||
|
m_phaseClockMs = 0.0;
|
||||||
|
m_blackOverlayAlpha = 0.0f;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
m_phase = Phase::FadeOutToBlack;
|
||||||
|
m_phaseClockMs = 0.0;
|
||||||
|
m_blackOverlayAlpha = 0.0f;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case Phase::FadeOutToBlack: {
|
||||||
|
m_phaseClockMs += frameMs;
|
||||||
|
const float t = (FADE_OUT_MS > 0.0) ? float(std::clamp(m_phaseClockMs / FADE_OUT_MS, 0.0, 1.0)) : 1.0f;
|
||||||
|
m_blackOverlayAlpha = t;
|
||||||
|
if (t >= 1.0f) {
|
||||||
|
// Switch to MAIN (Menu) with a fade-in from black.
|
||||||
|
if (ctx.startupFadeAlpha) {
|
||||||
|
*ctx.startupFadeAlpha = 1.0f;
|
||||||
|
}
|
||||||
|
if (ctx.startupFadeActive) {
|
||||||
|
*ctx.startupFadeActive = true;
|
||||||
|
}
|
||||||
|
if (ctx.stateManager) {
|
||||||
|
ctx.stateManager->setState(AppState::Menu);
|
||||||
|
}
|
||||||
|
m_phase = Phase::Done;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case Phase::Done:
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void VideoState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect logicalVP) {
|
||||||
|
(void)logicalScale;
|
||||||
|
(void)logicalVP;
|
||||||
|
|
||||||
|
if (!renderer) return;
|
||||||
|
|
||||||
|
int winW = 0, winH = 0;
|
||||||
|
SDL_GetRenderOutputSize(renderer, &winW, &winH);
|
||||||
|
|
||||||
|
// Draw video fullscreen if available.
|
||||||
|
if (m_player && m_player->isTextureReady()) {
|
||||||
|
SDL_SetRenderViewport(renderer, nullptr);
|
||||||
|
SDL_SetRenderScale(renderer, 1.0f, 1.0f);
|
||||||
|
m_player->render(renderer, winW, winH);
|
||||||
|
} else {
|
||||||
|
SDL_SetRenderDrawColor(renderer, 0, 0, 0, 255);
|
||||||
|
SDL_FRect r{0.f, 0.f, (float)winW, (float)winH};
|
||||||
|
SDL_RenderFillRect(renderer, &r);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply fade overlay (black).
|
||||||
|
if (m_blackOverlayAlpha > 0.0f) {
|
||||||
|
const Uint8 a = (Uint8)std::clamp((int)std::lround(m_blackOverlayAlpha * 255.0f), 0, 255);
|
||||||
|
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);
|
||||||
|
SDL_SetRenderDrawColor(renderer, 0, 0, 0, a);
|
||||||
|
SDL_FRect full{0.f, 0.f, (float)winW, (float)winH};
|
||||||
|
SDL_RenderFillRect(renderer, &full);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool VideoState::decodeAudioPcm16Stereo44100(
|
||||||
|
const std::string& path,
|
||||||
|
std::vector<int16_t>& outPcm,
|
||||||
|
int& outRate,
|
||||||
|
int& outChannels
|
||||||
|
) {
|
||||||
|
outPcm.clear();
|
||||||
|
outRate = 44100;
|
||||||
|
outChannels = 2;
|
||||||
|
|
||||||
|
AVFormatContext* fmt = nullptr;
|
||||||
|
if (avformat_open_input(&fmt, path.c_str(), nullptr, nullptr) != 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (avformat_find_stream_info(fmt, nullptr) < 0) {
|
||||||
|
avformat_close_input(&fmt);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
int audioStream = -1;
|
||||||
|
for (unsigned i = 0; i < fmt->nb_streams; ++i) {
|
||||||
|
if (fmt->streams[i]->codecpar->codec_type == AVMEDIA_TYPE_AUDIO) {
|
||||||
|
audioStream = (int)i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (audioStream < 0) {
|
||||||
|
avformat_close_input(&fmt);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
AVCodecParameters* codecpar = fmt->streams[audioStream]->codecpar;
|
||||||
|
const AVCodec* codec = avcodec_find_decoder(codecpar->codec_id);
|
||||||
|
if (!codec) {
|
||||||
|
avformat_close_input(&fmt);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
AVCodecContext* dec = avcodec_alloc_context3(codec);
|
||||||
|
if (!dec) {
|
||||||
|
avformat_close_input(&fmt);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (avcodec_parameters_to_context(dec, codecpar) < 0) {
|
||||||
|
avcodec_free_context(&dec);
|
||||||
|
avformat_close_input(&fmt);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (avcodec_open2(dec, codec, nullptr) < 0) {
|
||||||
|
avcodec_free_context(&dec);
|
||||||
|
avformat_close_input(&fmt);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
AVChannelLayout outLayout{};
|
||||||
|
av_channel_layout_default(&outLayout, 2);
|
||||||
|
|
||||||
|
AVChannelLayout inLayout{};
|
||||||
|
if (av_channel_layout_copy(&inLayout, &dec->ch_layout) < 0 || inLayout.nb_channels <= 0) {
|
||||||
|
av_channel_layout_uninit(&inLayout);
|
||||||
|
av_channel_layout_default(&inLayout, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
SwrContext* swr = nullptr;
|
||||||
|
if (swr_alloc_set_opts2(
|
||||||
|
&swr,
|
||||||
|
&outLayout,
|
||||||
|
AV_SAMPLE_FMT_S16,
|
||||||
|
44100,
|
||||||
|
&inLayout,
|
||||||
|
dec->sample_fmt,
|
||||||
|
dec->sample_rate,
|
||||||
|
0,
|
||||||
|
nullptr
|
||||||
|
) < 0) {
|
||||||
|
av_channel_layout_uninit(&inLayout);
|
||||||
|
av_channel_layout_uninit(&outLayout);
|
||||||
|
avcodec_free_context(&dec);
|
||||||
|
avformat_close_input(&fmt);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (swr_init(swr) < 0) {
|
||||||
|
swr_free(&swr);
|
||||||
|
av_channel_layout_uninit(&inLayout);
|
||||||
|
av_channel_layout_uninit(&outLayout);
|
||||||
|
avcodec_free_context(&dec);
|
||||||
|
avformat_close_input(&fmt);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
AVPacket* pkt = av_packet_alloc();
|
||||||
|
AVFrame* frame = av_frame_alloc();
|
||||||
|
if (!pkt || !frame) {
|
||||||
|
if (pkt) av_packet_free(&pkt);
|
||||||
|
if (frame) av_frame_free(&frame);
|
||||||
|
swr_free(&swr);
|
||||||
|
av_channel_layout_uninit(&inLayout);
|
||||||
|
av_channel_layout_uninit(&outLayout);
|
||||||
|
avcodec_free_context(&dec);
|
||||||
|
avformat_close_input(&fmt);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const int outRateConst = 44100;
|
||||||
|
const int outCh = 2;
|
||||||
|
|
||||||
|
while (av_read_frame(fmt, pkt) >= 0) {
|
||||||
|
if (pkt->stream_index != audioStream) {
|
||||||
|
av_packet_unref(pkt);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (avcodec_send_packet(dec, pkt) < 0) {
|
||||||
|
av_packet_unref(pkt);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
av_packet_unref(pkt);
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const int rr = avcodec_receive_frame(dec, frame);
|
||||||
|
if (rr == AVERROR(EAGAIN) || rr == AVERROR_EOF) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (rr < 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const int64_t delay = swr_get_delay(swr, dec->sample_rate);
|
||||||
|
const int dstNbSamples = (int)av_rescale_rnd(delay + frame->nb_samples, outRateConst, dec->sample_rate, AV_ROUND_UP);
|
||||||
|
|
||||||
|
std::vector<uint8_t> outBytes;
|
||||||
|
outBytes.resize((size_t)dstNbSamples * (size_t)outCh * sizeof(int16_t));
|
||||||
|
|
||||||
|
uint8_t* outData[1] = { outBytes.data() };
|
||||||
|
const uint8_t** inData = (const uint8_t**)frame->data;
|
||||||
|
|
||||||
|
const int converted = swr_convert(swr, outData, dstNbSamples, inData, frame->nb_samples);
|
||||||
|
if (converted > 0) {
|
||||||
|
const size_t samplesOut = (size_t)converted * (size_t)outCh;
|
||||||
|
const int16_t* asS16 = (const int16_t*)outBytes.data();
|
||||||
|
const size_t oldSize = outPcm.size();
|
||||||
|
outPcm.resize(oldSize + samplesOut);
|
||||||
|
std::memcpy(outPcm.data() + oldSize, asS16, samplesOut * sizeof(int16_t));
|
||||||
|
}
|
||||||
|
|
||||||
|
av_frame_unref(frame);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flush decoder
|
||||||
|
avcodec_send_packet(dec, nullptr);
|
||||||
|
while (avcodec_receive_frame(dec, frame) >= 0) {
|
||||||
|
const int64_t delay = swr_get_delay(swr, dec->sample_rate);
|
||||||
|
const int dstNbSamples = (int)av_rescale_rnd(delay + frame->nb_samples, outRateConst, dec->sample_rate, AV_ROUND_UP);
|
||||||
|
std::vector<uint8_t> outBytes;
|
||||||
|
outBytes.resize((size_t)dstNbSamples * (size_t)outCh * sizeof(int16_t));
|
||||||
|
uint8_t* outData[1] = { outBytes.data() };
|
||||||
|
const uint8_t** inData = (const uint8_t**)frame->data;
|
||||||
|
const int converted = swr_convert(swr, outData, dstNbSamples, inData, frame->nb_samples);
|
||||||
|
if (converted > 0) {
|
||||||
|
const size_t samplesOut = (size_t)converted * (size_t)outCh;
|
||||||
|
const int16_t* asS16 = (const int16_t*)outBytes.data();
|
||||||
|
const size_t oldSize = outPcm.size();
|
||||||
|
outPcm.resize(oldSize + samplesOut);
|
||||||
|
std::memcpy(outPcm.data() + oldSize, asS16, samplesOut * sizeof(int16_t));
|
||||||
|
}
|
||||||
|
av_frame_unref(frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
av_frame_free(&frame);
|
||||||
|
av_packet_free(&pkt);
|
||||||
|
swr_free(&swr);
|
||||||
|
av_channel_layout_uninit(&inLayout);
|
||||||
|
av_channel_layout_uninit(&outLayout);
|
||||||
|
avcodec_free_context(&dec);
|
||||||
|
avformat_close_input(&fmt);
|
||||||
|
|
||||||
|
outRate = outRateConst;
|
||||||
|
outChannels = outCh;
|
||||||
|
|
||||||
|
return !outPcm.empty();
|
||||||
|
}
|
||||||
67
src/states/VideoState.h
Normal file
67
src/states/VideoState.h
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
// VideoState.h
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "State.h"
|
||||||
|
|
||||||
|
#include <atomic>
|
||||||
|
#include <memory>
|
||||||
|
#include <string>
|
||||||
|
#include <thread>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
class VideoPlayer;
|
||||||
|
|
||||||
|
class VideoState : public State {
|
||||||
|
public:
|
||||||
|
explicit VideoState(StateContext& ctx);
|
||||||
|
~VideoState() override;
|
||||||
|
|
||||||
|
void onEnter() override;
|
||||||
|
void onExit() override;
|
||||||
|
void handleEvent(const SDL_Event& e) override;
|
||||||
|
void update(double frameMs) override;
|
||||||
|
void render(SDL_Renderer* renderer, float logicalScale, SDL_Rect logicalVP) override;
|
||||||
|
|
||||||
|
// Called from the App's on-enter hook so we can create textures.
|
||||||
|
bool begin(SDL_Renderer* renderer, const std::string& path);
|
||||||
|
|
||||||
|
private:
|
||||||
|
enum class Phase {
|
||||||
|
FadeInFirstFrame,
|
||||||
|
Playing,
|
||||||
|
FadeOutToBlack,
|
||||||
|
Done
|
||||||
|
};
|
||||||
|
|
||||||
|
void startAudioIfReady();
|
||||||
|
void stopAudio();
|
||||||
|
|
||||||
|
static bool decodeAudioPcm16Stereo44100(
|
||||||
|
const std::string& path,
|
||||||
|
std::vector<int16_t>& outPcm,
|
||||||
|
int& outRate,
|
||||||
|
int& outChannels
|
||||||
|
);
|
||||||
|
|
||||||
|
std::unique_ptr<VideoPlayer> m_player;
|
||||||
|
std::string m_path;
|
||||||
|
|
||||||
|
Phase m_phase = Phase::FadeInFirstFrame;
|
||||||
|
double m_phaseClockMs = 0.0;
|
||||||
|
|
||||||
|
static constexpr double FADE_IN_MS = 900.0;
|
||||||
|
static constexpr double FADE_OUT_MS = 450.0;
|
||||||
|
|
||||||
|
// Audio decoding runs in the background while we fade in.
|
||||||
|
std::atomic<bool> m_audioDecoded{false};
|
||||||
|
std::atomic<bool> m_audioDecodeFailed{false};
|
||||||
|
std::vector<int16_t> m_audioPcm;
|
||||||
|
int m_audioRate = 44100;
|
||||||
|
int m_audioChannels = 2;
|
||||||
|
bool m_audioStarted = false;
|
||||||
|
|
||||||
|
std::unique_ptr<std::jthread> m_audioThread;
|
||||||
|
|
||||||
|
// Render-time overlay alpha (0..1) for fade stages.
|
||||||
|
float m_blackOverlayAlpha = 1.0f;
|
||||||
|
};
|
||||||
172
src/video/VideoPlayer.cpp
Normal file
172
src/video/VideoPlayer.cpp
Normal file
@ -0,0 +1,172 @@
|
|||||||
|
#include "VideoPlayer.h"
|
||||||
|
|
||||||
|
#include <iostream>
|
||||||
|
#include <chrono>
|
||||||
|
|
||||||
|
extern "C" {
|
||||||
|
#include <libavformat/avformat.h>
|
||||||
|
#include <libavcodec/avcodec.h>
|
||||||
|
#include <libswscale/swscale.h>
|
||||||
|
#include <libavutil/imgutils.h>
|
||||||
|
}
|
||||||
|
|
||||||
|
VideoPlayer::VideoPlayer() {}
|
||||||
|
|
||||||
|
VideoPlayer::~VideoPlayer() {
|
||||||
|
if (m_texture) SDL_DestroyTexture(m_texture);
|
||||||
|
if (m_rgbBuffer) av_free(m_rgbBuffer);
|
||||||
|
if (m_frame) av_frame_free(&m_frame);
|
||||||
|
if (m_sws) sws_freeContext(m_sws);
|
||||||
|
if (m_dec) avcodec_free_context(&m_dec);
|
||||||
|
if (m_fmt) avformat_close_input(&m_fmt);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool VideoPlayer::open(const std::string& path, SDL_Renderer* renderer) {
|
||||||
|
m_path = path;
|
||||||
|
avformat_network_init();
|
||||||
|
if (avformat_open_input(&m_fmt, path.c_str(), nullptr, nullptr) != 0) {
|
||||||
|
std::cerr << "VideoPlayer: failed to open " << path << "\n";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (avformat_find_stream_info(m_fmt, nullptr) < 0) {
|
||||||
|
std::cerr << "VideoPlayer: failed to find stream info\n";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Find video stream
|
||||||
|
m_videoStream = -1;
|
||||||
|
for (unsigned i = 0; i < m_fmt->nb_streams; ++i) {
|
||||||
|
if (m_fmt->streams[i]->codecpar->codec_type == AVMEDIA_TYPE_VIDEO) { m_videoStream = (int)i; break; }
|
||||||
|
}
|
||||||
|
if (m_videoStream < 0) { std::cerr << "VideoPlayer: no video stream\n"; return false; }
|
||||||
|
|
||||||
|
AVCodecParameters* codecpar = m_fmt->streams[m_videoStream]->codecpar;
|
||||||
|
const AVCodec* codec = avcodec_find_decoder(codecpar->codec_id);
|
||||||
|
if (!codec) { std::cerr << "VideoPlayer: decoder not found\n"; return false; }
|
||||||
|
m_dec = avcodec_alloc_context3(codec);
|
||||||
|
if (!m_dec) { std::cerr << "VideoPlayer: failed to alloc codec ctx\n"; return false; }
|
||||||
|
if (avcodec_parameters_to_context(m_dec, codecpar) < 0) { std::cerr << "VideoPlayer: param to ctx failed\n"; return false; }
|
||||||
|
if (avcodec_open2(m_dec, codec, nullptr) < 0) { std::cerr << "VideoPlayer: open codec failed\n"; return false; }
|
||||||
|
|
||||||
|
m_width = m_dec->width;
|
||||||
|
m_height = m_dec->height;
|
||||||
|
m_frame = av_frame_alloc();
|
||||||
|
m_sws = sws_getContext(m_width, m_height, m_dec->pix_fmt, m_width, m_height, AV_PIX_FMT_RGBA, SWS_BILINEAR, nullptr, nullptr, nullptr);
|
||||||
|
m_rgbBufferSize = av_image_get_buffer_size(AV_PIX_FMT_RGBA, m_width, m_height, 1);
|
||||||
|
m_rgbBuffer = (uint8_t*)av_malloc(m_rgbBufferSize);
|
||||||
|
|
||||||
|
if (renderer) {
|
||||||
|
m_texture = SDL_CreateTexture(renderer, SDL_PIXELFORMAT_RGBA32, SDL_TEXTUREACCESS_STREAMING, m_width, m_height);
|
||||||
|
if (!m_texture) { std::cerr << "VideoPlayer: failed create texture\n"; }
|
||||||
|
}
|
||||||
|
|
||||||
|
m_finished = false;
|
||||||
|
m_textureReady = false;
|
||||||
|
m_started = false;
|
||||||
|
m_frameAccumulatorMs = 0.0;
|
||||||
|
|
||||||
|
// Estimate frame interval.
|
||||||
|
m_frameIntervalMs = 33.333;
|
||||||
|
if (m_fmt && m_videoStream >= 0) {
|
||||||
|
AVRational fr = m_fmt->streams[m_videoStream]->avg_frame_rate;
|
||||||
|
if (fr.num > 0 && fr.den > 0) {
|
||||||
|
const double fps = av_q2d(fr);
|
||||||
|
if (fps > 1.0) {
|
||||||
|
m_frameIntervalMs = 1000.0 / fps;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seek to start
|
||||||
|
av_seek_frame(m_fmt, m_videoStream, 0, AVSEEK_FLAG_BACKWARD);
|
||||||
|
if (m_dec) avcodec_flush_buffers(m_dec);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool VideoPlayer::decodeOneFrame() {
|
||||||
|
if (m_finished || !m_fmt) return false;
|
||||||
|
|
||||||
|
AVPacket* pkt = av_packet_alloc();
|
||||||
|
if (!pkt) {
|
||||||
|
m_finished = true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
int ret = 0;
|
||||||
|
while (av_read_frame(m_fmt, pkt) >= 0) {
|
||||||
|
if (pkt->stream_index == m_videoStream) {
|
||||||
|
ret = avcodec_send_packet(m_dec, pkt);
|
||||||
|
if (ret < 0) {
|
||||||
|
av_packet_unref(pkt);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
while (ret >= 0) {
|
||||||
|
ret = avcodec_receive_frame(m_dec, m_frame);
|
||||||
|
if (ret == AVERROR(EAGAIN) || ret == AVERROR_EOF) break;
|
||||||
|
if (ret < 0) break;
|
||||||
|
|
||||||
|
uint8_t* dstData[4] = { m_rgbBuffer, nullptr, nullptr, nullptr };
|
||||||
|
int dstLinesize[4] = { m_width * 4, 0, 0, 0 };
|
||||||
|
sws_scale(m_sws, m_frame->data, m_frame->linesize, 0, m_height, dstData, dstLinesize);
|
||||||
|
m_textureReady = true;
|
||||||
|
if (m_texture) {
|
||||||
|
SDL_UpdateTexture(m_texture, nullptr, m_rgbBuffer, dstLinesize[0]);
|
||||||
|
}
|
||||||
|
av_frame_unref(m_frame);
|
||||||
|
|
||||||
|
av_packet_unref(pkt);
|
||||||
|
av_packet_free(&pkt);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
av_packet_unref(pkt);
|
||||||
|
}
|
||||||
|
|
||||||
|
av_packet_free(&pkt);
|
||||||
|
m_finished = true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool VideoPlayer::decodeFirstFrame() {
|
||||||
|
if (!m_fmt || m_finished) return false;
|
||||||
|
if (m_textureReady) return true;
|
||||||
|
// Ensure we are at the beginning.
|
||||||
|
av_seek_frame(m_fmt, m_videoStream, 0, AVSEEK_FLAG_BACKWARD);
|
||||||
|
if (m_dec) avcodec_flush_buffers(m_dec);
|
||||||
|
return decodeOneFrame();
|
||||||
|
}
|
||||||
|
|
||||||
|
void VideoPlayer::start() {
|
||||||
|
m_started = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool VideoPlayer::update(double deltaMs) {
|
||||||
|
if (m_finished || !m_fmt) return false;
|
||||||
|
if (!m_started) return true;
|
||||||
|
|
||||||
|
m_frameAccumulatorMs += deltaMs;
|
||||||
|
|
||||||
|
// Decode at most a small burst per frame to avoid spiral-of-death.
|
||||||
|
int framesDecoded = 0;
|
||||||
|
const int maxFramesPerTick = 4;
|
||||||
|
while (m_frameAccumulatorMs >= m_frameIntervalMs && framesDecoded < maxFramesPerTick) {
|
||||||
|
m_frameAccumulatorMs -= m_frameIntervalMs;
|
||||||
|
if (!decodeOneFrame()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
++framesDecoded;
|
||||||
|
}
|
||||||
|
return !m_finished;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool VideoPlayer::update() {
|
||||||
|
// Legacy behavior: decode exactly one frame.
|
||||||
|
return decodeOneFrame();
|
||||||
|
}
|
||||||
|
|
||||||
|
void VideoPlayer::render(SDL_Renderer* renderer, int winW, int winH) {
|
||||||
|
if (!m_textureReady || !m_texture || !renderer) return;
|
||||||
|
if (winW <= 0 || winH <= 0) return;
|
||||||
|
SDL_FRect dst = { 0.0f, 0.0f, (float)winW, (float)winH };
|
||||||
|
SDL_RenderTexture(renderer, m_texture, nullptr, &dst);
|
||||||
|
}
|
||||||
59
src/video/VideoPlayer.h
Normal file
59
src/video/VideoPlayer.h
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
// Minimal FFmpeg-based video player (video) that decodes into an SDL texture.
|
||||||
|
// Audio for the intro is currently handled outside this class.
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
#include <SDL3/SDL.h>
|
||||||
|
|
||||||
|
struct AVFormatContext;
|
||||||
|
struct AVCodecContext;
|
||||||
|
struct SwsContext;
|
||||||
|
struct AVFrame;
|
||||||
|
|
||||||
|
class VideoPlayer {
|
||||||
|
public:
|
||||||
|
VideoPlayer();
|
||||||
|
~VideoPlayer();
|
||||||
|
|
||||||
|
// Open video file and attach to SDL_Renderer for texture creation
|
||||||
|
bool open(const std::string& path, SDL_Renderer* renderer);
|
||||||
|
// Decode the first frame immediately so it can be used for fade-in.
|
||||||
|
bool decodeFirstFrame();
|
||||||
|
|
||||||
|
// Start time-based playback.
|
||||||
|
void start();
|
||||||
|
|
||||||
|
// Update playback using elapsed time in milliseconds.
|
||||||
|
// Returns false if finished or error.
|
||||||
|
bool update(double deltaMs);
|
||||||
|
|
||||||
|
// Compatibility: advance by one decoded frame.
|
||||||
|
bool update();
|
||||||
|
|
||||||
|
// Render video frame fullscreen to the given renderer using provided output size.
|
||||||
|
void render(SDL_Renderer* renderer, int winW, int winH);
|
||||||
|
bool isFinished() const { return m_finished; }
|
||||||
|
bool isTextureReady() const { return m_textureReady; }
|
||||||
|
|
||||||
|
double getFrameIntervalMs() const { return m_frameIntervalMs; }
|
||||||
|
bool isStarted() const { return m_started; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
bool decodeOneFrame();
|
||||||
|
|
||||||
|
AVFormatContext* m_fmt = nullptr;
|
||||||
|
AVCodecContext* m_dec = nullptr;
|
||||||
|
SwsContext* m_sws = nullptr;
|
||||||
|
AVFrame* m_frame = nullptr;
|
||||||
|
int m_videoStream = -1;
|
||||||
|
double m_frameIntervalMs = 33.333;
|
||||||
|
double m_frameAccumulatorMs = 0.0;
|
||||||
|
bool m_started = false;
|
||||||
|
int m_width = 0, m_height = 0;
|
||||||
|
SDL_Texture* m_texture = nullptr;
|
||||||
|
uint8_t* m_rgbBuffer = nullptr;
|
||||||
|
int m_rgbBufferSize = 0;
|
||||||
|
bool m_textureReady = false;
|
||||||
|
bool m_finished = true;
|
||||||
|
std::string m_path;
|
||||||
|
};
|
||||||
@ -9,6 +9,7 @@
|
|||||||
"enet",
|
"enet",
|
||||||
"catch2",
|
"catch2",
|
||||||
"cpr",
|
"cpr",
|
||||||
"nlohmann-json"
|
"nlohmann-json",
|
||||||
|
"ffmpeg"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user