diff --git a/CMakeLists.txt b/CMakeLists.txt index b530b2b..0c8044c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -29,7 +29,7 @@ find_package(SDL3_image CONFIG REQUIRED) find_package(cpr CONFIG REQUIRED) find_package(nlohmann_json CONFIG REQUIRED) -add_executable(tetris +set(TETRIS_SOURCES src/main.cpp src/gameplay/core/Game.cpp src/core/GravityManager.cpp @@ -58,12 +58,24 @@ add_executable(tetris src/states/PlayingState.cpp ) +if(APPLE) + add_executable(tetris MACOSX_BUNDLE ${TETRIS_SOURCES}) +else() + add_executable(tetris ${TETRIS_SOURCES}) +endif() + if (WIN32) # Embed the application icon into the executable set_source_files_properties(src/app_icon.rc PROPERTIES LANGUAGE RC) target_sources(tetris PRIVATE src/app_icon.rc) endif() +if(APPLE) + set_target_properties(tetris PROPERTIES + MACOSX_BUNDLE_INFO_PLIST "${CMAKE_SOURCE_DIR}/cmake/MacBundleInfo.plist.in" + ) +endif() + if (WIN32) # Ensure favicon.ico is available in the build directory for the resource compiler set(FAVICON_SRC "${CMAKE_SOURCE_DIR}/assets/favicon/favicon.ico") @@ -88,6 +100,31 @@ if (WIN32) ) endif() +if(APPLE) + set(_mac_copy_commands) + if(EXISTS "${CMAKE_SOURCE_DIR}/assets") + list(APPEND _mac_copy_commands + COMMAND ${CMAKE_COMMAND} -E copy_directory "${CMAKE_SOURCE_DIR}/assets" "$/assets" + ) + endif() + if(EXISTS "${CMAKE_SOURCE_DIR}/fonts") + list(APPEND _mac_copy_commands + COMMAND ${CMAKE_COMMAND} -E copy_directory "${CMAKE_SOURCE_DIR}/fonts" "$/fonts" + ) + endif() + if(EXISTS "${CMAKE_SOURCE_DIR}/FreeSans.ttf") + list(APPEND _mac_copy_commands + COMMAND ${CMAKE_COMMAND} -E copy "${CMAKE_SOURCE_DIR}/FreeSans.ttf" "$/FreeSans.ttf" + ) + endif() + if(_mac_copy_commands) + add_custom_command(TARGET tetris POST_BUILD + ${_mac_copy_commands} + COMMENT "Copying game assets into macOS bundle" + ) + endif() +endif() + target_link_libraries(tetris PRIVATE SDL3::SDL3 SDL3_ttf::SDL3_ttf SDL3_image::SDL3_image cpr::cpr nlohmann_json::nlohmann_json) if (WIN32) diff --git a/assets/music/Every Block You Take.ogg b/assets/music/Every Block You Take.ogg new file mode 100644 index 0000000..f3857b1 Binary files /dev/null and b/assets/music/Every Block You Take.ogg differ diff --git a/assets/music/amazing.ogg b/assets/music/amazing.ogg new file mode 100644 index 0000000..fe54410 Binary files /dev/null and b/assets/music/amazing.ogg differ diff --git a/assets/music/boom_tetris.ogg b/assets/music/boom_tetris.ogg new file mode 100644 index 0000000..00e9c3d Binary files /dev/null and b/assets/music/boom_tetris.ogg differ diff --git a/assets/music/great_move.ogg b/assets/music/great_move.ogg new file mode 100644 index 0000000..4045c7e Binary files /dev/null and b/assets/music/great_move.ogg differ diff --git a/assets/music/hard_drop_001.ogg b/assets/music/hard_drop_001.ogg new file mode 100644 index 0000000..81d7f32 Binary files /dev/null and b/assets/music/hard_drop_001.ogg differ diff --git a/assets/music/impressive.ogg b/assets/music/impressive.ogg new file mode 100644 index 0000000..e0eecf9 Binary files /dev/null and b/assets/music/impressive.ogg differ diff --git a/assets/music/keep_that_ryhtm.ogg b/assets/music/keep_that_ryhtm.ogg new file mode 100644 index 0000000..562c2f3 Binary files /dev/null and b/assets/music/keep_that_ryhtm.ogg differ diff --git a/assets/music/lets_go.ogg b/assets/music/lets_go.ogg new file mode 100644 index 0000000..caab19a Binary files /dev/null and b/assets/music/lets_go.ogg differ diff --git a/assets/music/new_level.ogg b/assets/music/new_level.ogg new file mode 100644 index 0000000..116b95d Binary files /dev/null and b/assets/music/new_level.ogg differ diff --git a/assets/music/nice_combo.ogg b/assets/music/nice_combo.ogg new file mode 100644 index 0000000..3b8c400 Binary files /dev/null and b/assets/music/nice_combo.ogg differ diff --git a/assets/music/smooth_clear.ogg b/assets/music/smooth_clear.ogg new file mode 100644 index 0000000..87bf3d7 Binary files /dev/null and b/assets/music/smooth_clear.ogg differ diff --git a/assets/music/triple_strike.ogg b/assets/music/triple_strike.ogg new file mode 100644 index 0000000..f96843c Binary files /dev/null and b/assets/music/triple_strike.ogg differ diff --git a/assets/music/well_played.ogg b/assets/music/well_played.ogg new file mode 100644 index 0000000..5e199e0 Binary files /dev/null and b/assets/music/well_played.ogg differ diff --git a/assets/music/wonderful.ogg b/assets/music/wonderful.ogg new file mode 100644 index 0000000..1bfee75 Binary files /dev/null and b/assets/music/wonderful.ogg differ diff --git a/assets/music/you_fire.ogg b/assets/music/you_fire.ogg new file mode 100644 index 0000000..3fabd06 Binary files /dev/null and b/assets/music/you_fire.ogg differ diff --git a/assets/music/you_re_unstoppable.ogg b/assets/music/you_re_unstoppable.ogg new file mode 100644 index 0000000..ed0fdf8 Binary files /dev/null and b/assets/music/you_re_unstoppable.ogg differ diff --git a/build-production-mac.sh b/build-production-mac.sh new file mode 100644 index 0000000..3ca9424 --- /dev/null +++ b/build-production-mac.sh @@ -0,0 +1,313 @@ +#!/usr/bin/env bash +set -euo pipefail + +# macOS Production Build Script for the SDL3 Tetris project +# Mirrors the Windows PowerShell workflow but uses common POSIX tooling so it +# can be executed on macOS runners or local developer machines. + +PROJECT_NAME="tetris" +BUILD_DIR="build-release" +OUTPUT_DIR="dist" +PACKAGE_DIR="" +VERSION="$(date +"%Y.%m.%d")" +CLEAN=0 +PACKAGE_ONLY=0 +PACKAGE_RUNTIME_DIR="" + +print_usage() { + cat <<'USAGE' +Usage: ./build-production-mac.sh [options] + +Options: + -c, --clean Remove existing build + dist folders before running + -p, --package-only Skip compilation and only rebuild the distributable + -o, --output DIR Customize output directory (default: dist) + -h, --help Show this help text +USAGE +} + +log() { + local level=$1; shift + case "$level" in + INFO) printf '\033[36m[INFO]\033[0m %s\n' "$*" ;; + OK) printf '\033[32m[ OK ]\033[0m %s\n' "$*" ;; + WARN) printf '\033[33m[WARN]\033[0m %s\n' "$*" ;; + ERR) printf '\033[31m[ERR ]\033[0m %s\n' "$*" ;; + *) printf '%s\n' "$*" ;; + esac +} + +require_macos() { + if [[ $(uname) != "Darwin" ]]; then + log ERR "This script is intended for macOS hosts." + exit 1 + fi +} + +parse_args() { + while [[ $# -gt 0 ]]; do + case "$1" in + -c|--clean) CLEAN=1; shift ;; + -p|--package-only) PACKAGE_ONLY=1; shift ;; + -o|--output) + if [[ $# -lt 2 ]]; then + log ERR "--output requires a directory argument" + exit 1 + fi + OUTPUT_DIR="$2"; shift 2 ;; + -h|--help) print_usage; exit 0 ;; + *) + log ERR "Unknown argument: $1" + print_usage + exit 1 ;; + esac + done +} + +configure_paths() { + PACKAGE_DIR="${OUTPUT_DIR}/TetrisGame-mac" +} + +clean_previous() { + if (( CLEAN )); then + log INFO "Cleaning previous build artifacts..." + rm -rf "$BUILD_DIR" "$OUTPUT_DIR" + log OK "Previous artifacts removed" + fi +} + +configure_and_build() { + if (( PACKAGE_ONLY )); then + return + fi + + log INFO "Configuring CMake (Release)..." + cmake -S . -B "$BUILD_DIR" -DCMAKE_BUILD_TYPE=Release + log OK "CMake configure complete" + + log INFO "Building Release target..." + cmake --build "$BUILD_DIR" --config Release + log OK "Build finished" +} + +resolve_executable() { + local candidates=( + "$BUILD_DIR/Release/${PROJECT_NAME}" + "$BUILD_DIR/${PROJECT_NAME}" + "$BUILD_DIR/${PROJECT_NAME}.app/Contents/MacOS/${PROJECT_NAME}" + ) + + for path in "${candidates[@]}"; do + if [[ -x "$path" ]]; then + EXECUTABLE_PATH="$path" + break + fi + done + + if [[ -z ${EXECUTABLE_PATH:-} ]]; then + log ERR "Unable to locate built executable." + exit 1 + fi + + if [[ "$EXECUTABLE_PATH" == *.app/Contents/MacOS/* ]]; then + APP_BUNDLE_PATH="${EXECUTABLE_PATH%/Contents/MacOS/*}" + else + APP_BUNDLE_PATH="" + fi + + log OK "Using executable: $EXECUTABLE_PATH" +} + +prepare_package_dir() { + log INFO "Creating package directory $PACKAGE_DIR ..." + rm -rf "$PACKAGE_DIR" + mkdir -p "$PACKAGE_DIR" +} + +copy_binary_or_bundle() { + if [[ -n ${APP_BUNDLE_PATH:-} ]]; then + log INFO "Copying app bundle..." + rsync -a "$APP_BUNDLE_PATH" "$PACKAGE_DIR/" + local app_name="${APP_BUNDLE_PATH##*/}" + PACKAGE_BINARY="${app_name}/Contents/MacOS/${PROJECT_NAME}" + PACKAGE_RUNTIME_DIR="$PACKAGE_DIR/${app_name}/Contents/MacOS" + else + log INFO "Copying executable..." + cp "$EXECUTABLE_PATH" "$PACKAGE_DIR/${PROJECT_NAME}" + chmod +x "$PACKAGE_DIR/${PROJECT_NAME}" + PACKAGE_BINARY="${PROJECT_NAME}" + PACKAGE_RUNTIME_DIR="$PACKAGE_DIR" + fi + log OK "Binary ready (${PACKAGE_BINARY})" +} + +copy_assets() { + if [[ -n ${APP_BUNDLE_PATH:-} ]]; then + log INFO "Assets already bundled inside the .app; skipping external copy." + return + fi + log INFO "Copying assets..." + local folders=("assets" "fonts") + for folder in "${folders[@]}"; do + if [[ -d "$folder" ]]; then + rsync -a "$folder" "$PACKAGE_DIR/" + log OK "Copied $folder" + fi + done + if [[ -f "FreeSans.ttf" ]]; then + cp "FreeSans.ttf" "$PACKAGE_DIR/" + log OK "Copied FreeSans.ttf" + fi +} + +copy_dependencies() { + log INFO "Collecting dynamic libraries from vcpkg..." + local triplets=("arm64-osx" "x64-osx" "universal-osx") + local bases=("$BUILD_DIR/vcpkg_installed" "vcpkg_installed") + local copied_names=() + + for base in "${bases[@]}"; do + for triplet in "${triplets[@]}"; do + for sub in lib bin; do + local dir="$base/$triplet/$sub" + if [[ -d "$dir" ]]; then + while IFS= read -r -d '' dylib; do + local name=$(basename "$dylib") + local seen=0 + for existing in "${copied_names[@]}"; do + if [[ "$existing" == "$name" ]]; then + seen=1 + break + fi + done + if (( !seen )); then + cp "$dylib" "$PACKAGE_RUNTIME_DIR/" + copied_names+=("$name") + log OK "Copied $name" + fi + done < <(find "$dir" -maxdepth 1 -type f -name '*.dylib' -print0) + fi + done + done + done + + if [[ ${#copied_names[@]} -eq 0 ]]; then + log WARN "No .dylib files found; ensure vcpkg installed macOS triplet dependencies." + fi +} + +ensure_rpath() { + local binary="$PACKAGE_DIR/$PACKAGE_BINARY" + if [[ ! -f "$binary" ]]; then + return + fi + if command -v otool >/dev/null 2>&1 && command -v install_name_tool >/dev/null 2>&1; then + if ! otool -l "$binary" | grep -A2 LC_RPATH | grep -q '@executable_path'; then + log INFO "Adding @executable_path rpath" + install_name_tool -add_rpath "@executable_path" "$binary" + fi + fi +} + +create_launchers() { + local launch_command="./${PROJECT_NAME}" + if [[ "$PACKAGE_BINARY" == *.app/Contents/MacOS/* ]]; then + local app_dir="${PACKAGE_BINARY%%/Contents/*}" + launch_command="open \"./${app_dir}\"" + fi + + cat > "$PACKAGE_DIR/Launch-Tetris.command" < "$PACKAGE_DIR/README-mac.txt" < 0 )); then + log WARN "Missing: ${missing[*]}" + else + log OK "Package looks complete" + fi +} + +create_zip() { + mkdir -p "$OUTPUT_DIR" + local zip_name="TetrisGame-mac-${VERSION}.zip" + local zip_path="$OUTPUT_DIR/$zip_name" + log INFO "Creating zip archive $zip_path ..." + if command -v ditto >/dev/null 2>&1; then + ditto -c -k --keepParent "$PACKAGE_DIR" "$zip_path" + else + (cd "$OUTPUT_DIR" && zip -r "$zip_name" "$(basename "$PACKAGE_DIR")") + fi + log OK "Zip created" +} + +main() { + parse_args "$@" + require_macos + configure_paths + + if [[ ! -f "CMakeLists.txt" ]]; then + log ERR "Run from repository root (CMakeLists.txt missing)." + exit 1 + fi + + log INFO "======================================" + log INFO " macOS Production Builder" + log INFO "======================================" + log INFO "Version: $VERSION" + log INFO "Output: $OUTPUT_DIR" + + clean_previous + configure_and_build + resolve_executable + prepare_package_dir + copy_binary_or_bundle + copy_assets + copy_dependencies + ensure_rpath + create_launchers + validate_package + create_zip + + log INFO "Done. Package available at $PACKAGE_DIR" +} + +main "$@" diff --git a/cmake/MacBundleInfo.plist.in b/cmake/MacBundleInfo.plist.in new file mode 100644 index 0000000..2edadcf --- /dev/null +++ b/cmake/MacBundleInfo.plist.in @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + English + CFBundleExecutable + ${MACOSX_BUNDLE_EXECUTABLE_NAME} + CFBundleIdentifier + com.example.tetris + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + Tetris + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1.0 + LSMinimumSystemVersion + 12.0 + NSHighResolutionCapable + + + diff --git a/cmake/ProductionBuild.cmake b/cmake/ProductionBuild.cmake index d81c519..e448d21 100644 --- a/cmake/ProductionBuild.cmake +++ b/cmake/ProductionBuild.cmake @@ -63,10 +63,17 @@ if(WIN32) endif() # Installation rules for system-wide installation -install(TARGETS tetris - RUNTIME DESTINATION bin - COMPONENT Runtime -) +if(APPLE) + install(TARGETS tetris + BUNDLE DESTINATION . + COMPONENT Runtime + ) +else() + install(TARGETS tetris + RUNTIME DESTINATION bin + COMPONENT Runtime + ) +endif() install(DIRECTORY assets/ DESTINATION share/tetris/assets diff --git a/convert_instructions.bat b/convert_instructions.bat index bf2da84..41a3dea 100644 --- a/convert_instructions.bat +++ b/convert_instructions.bat @@ -1,14 +1,14 @@ @echo off -echo Converting MP3 files to WAV using Windows Media Player... +echo Convert MP3 files to OGG (preferred) for cross-platform playback... echo. REM Check if we have access to Windows Media Format SDK set MUSIC_DIR=assets\music REM List of MP3 files to convert -set FILES=amazing.mp3 boom_tetris.mp3 great_move.mp3 impressive.mp3 keep_that_ryhtm.mp3 lets_go.mp3 nice_combo.mp3 smooth_clear.mp3 triple_strike.mp3 well_played.mp3 wonderful.mp3 you_fire.mp3 you_re_unstoppable.mp3 +set FILES=amazing.mp3 boom_tetris.mp3 great_move.mp3 impressive.mp3 keep_that_ryhtm.mp3 lets_go.mp3 nice_combo.mp3 smooth_clear.mp3 triple_strike.mp3 well_played.mp3 wonderful.mp3 you_fire.mp3 you_re_unstoppable.mp3 hard_drop_001.mp3 new_level.mp3 -echo Please convert these MP3 files to WAV format manually: +echo Please convert these MP3 files to OGG Vorbis manually (or run convert_to_ogg.ps1 on Windows): echo. for %%f in (%FILES%) do ( echo - %MUSIC_DIR%\%%f @@ -17,13 +17,12 @@ for %%f in (%FILES%) do ( echo. echo Recommended settings for conversion: echo - Sample Rate: 44100 Hz -echo - Bit Depth: 16-bit echo - Channels: Stereo (2) -echo - Format: PCM WAV +echo - Use OGG Vorbis quality ~4 (or convert to FLAC if you prefer lossless) echo. echo You can use: echo - Audacity (free): https://www.audacityteam.org/ echo - VLC Media Player (free): Media ^> Convert/Save -echo - Any audio converter software +echo - ffmpeg (CLI): ffmpeg -i input.mp3 -c:a libvorbis -qscale:a 4 output.ogg echo. pause diff --git a/convert_to_ogg.ps1 b/convert_to_ogg.ps1 new file mode 100644 index 0000000..35b5da8 --- /dev/null +++ b/convert_to_ogg.ps1 @@ -0,0 +1,45 @@ +# Convert MP3 sound effects to OGG Vorbis format for cross-platform playback +# Requires ffmpeg (https://ffmpeg.org/). OGG keeps files small while SDL's decoders +# work everywhere the game ships. + +$musicDir = "assets\music" +$sourceFiles = @( + "amazing.mp3", + "boom_tetris.mp3", + "great_move.mp3", + "impressive.mp3", + "keep_that_ryhtm.mp3", + "lets_go.mp3", + "nice_combo.mp3", + "smooth_clear.mp3", + "triple_strike.mp3", + "well_played.mp3", + "wonderful.mp3", + "you_fire.mp3", + "you_re_unstoppable.mp3", + "hard_drop_001.mp3", + "new_level.mp3", + "Every Block You Take.mp3" +) + +if (!(Get-Command ffmpeg -ErrorAction SilentlyContinue)) { + Write-Host "ffmpeg is required. Install via https://ffmpeg.org/ or winget install Gyan.FFmpeg" -ForegroundColor Red + exit 1 +} + +Write-Host "Converting MP3 sound effects to OGG..." -ForegroundColor Cyan +foreach ($file in $sourceFiles) { + $src = Join-Path $musicDir $file + if (!(Test-Path $src)) { + Write-Host "Skipping (missing): $src" -ForegroundColor Yellow + continue + } + $ogg = ($file -replace ".mp3$", ".ogg") + $dest = Join-Path $musicDir $ogg + Write-Host "-> $ogg" -ForegroundColor Green + & ffmpeg -y -i $src -c:a libvorbis -qscale:a 4 $dest > $null 2>&1 + if ($LASTEXITCODE -ne 0) { + Write-Host "Failed to convert $file" -ForegroundColor Red + } +} +Write-Host "Conversion complete." -ForegroundColor Green diff --git a/convert_to_wav.ps1 b/convert_to_wav.ps1 index 577f52b..d77e27f 100644 --- a/convert_to_wav.ps1 +++ b/convert_to_wav.ps1 @@ -1,63 +1,11 @@ -# Convert MP3 sound effects to WAV format -# This script converts all MP3 sound effect files to WAV for better compatibility +# Deprecated shim: point developers to the OGG conversion workflow +Write-Host "convert_to_wav.ps1 is deprecated." -ForegroundColor Yellow +Write-Host "Use convert_to_ogg.ps1 for generating assets with OGG Vorbis." -ForegroundColor Yellow -$musicDir = "assets\music" -$mp3Files = @( - "amazing.mp3", - "boom_tetris.mp3", - "great_move.mp3", - "impressive.mp3", - "keep_that_ryhtm.mp3", - "lets_go.mp3", - "nice_combo.mp3", - "smooth_clear.mp3", - "triple_strike.mp3", - "well_played.mp3", - "wonderful.mp3", - "you_fire.mp3", - "you_re_unstoppable.mp3" -) - -Write-Host "Converting MP3 sound effects to WAV format..." -ForegroundColor Green - -foreach ($mp3File in $mp3Files) { - $mp3Path = Join-Path $musicDir $mp3File - $wavFile = $mp3File -replace "\.mp3$", ".wav" - $wavPath = Join-Path $musicDir $wavFile - - if (Test-Path $mp3Path) { - Write-Host "Converting $mp3File to $wavFile..." -ForegroundColor Yellow - - # Try ffmpeg first (most common) - $ffmpegResult = $null - try { - $ffmpegResult = & ffmpeg -i $mp3Path -acodec pcm_s16le -ar 44100 -ac 2 $wavPath -y 2>&1 - if ($LASTEXITCODE -eq 0) { - Write-Host "✓ Successfully converted $mp3File" -ForegroundColor Green - continue - } - } catch { - # FFmpeg not available, try other methods - } - - # Try Windows Media Format SDK (if available) - try { - Add-Type -AssemblyName System.Windows.Forms - Add-Type -AssemblyName Microsoft.VisualBasic - - # Use Windows built-in audio conversion - $shell = New-Object -ComObject Shell.Application - # This is a fallback method - may not work on all systems - Write-Host "⚠ FFmpeg not found. Please install FFmpeg or convert manually." -ForegroundColor Red - } catch { - Write-Host "⚠ Could not convert $mp3File automatically." -ForegroundColor Red - } - } else { - Write-Host "⚠ File not found: $mp3Path" -ForegroundColor Red - } +$oggScript = Join-Path $PSScriptRoot "convert_to_ogg.ps1" +if (Test-Path $oggScript) { + & $oggScript +} else { + Write-Host "Missing convert_to_ogg.ps1" -ForegroundColor Red + exit 1 } - -Write-Host "`nConversion complete! If FFmpeg was not found, please:" -ForegroundColor Cyan -Write-Host "1. Install FFmpeg: https://ffmpeg.org/download.html" -ForegroundColor White -Write-Host "2. Or use an audio converter like Audacity" -ForegroundColor White -Write-Host "3. Convert to: 44.1kHz, 16-bit, Stereo WAV" -ForegroundColor White diff --git a/settings.ini b/settings.ini index 200523e..384ce2a 100644 --- a/settings.ini +++ b/settings.ini @@ -12,7 +12,7 @@ Sound=1 SmoothScroll=1 [Player] -Name=GREGOR +Name=PLAYER [Debug] Enabled=1 diff --git a/src/core/application/ApplicationManager.cpp b/src/core/application/ApplicationManager.cpp index c50bdd4..d6e26d6 100644 --- a/src/core/application/ApplicationManager.cpp +++ b/src/core/application/ApplicationManager.cpp @@ -558,16 +558,20 @@ bool ApplicationManager::initializeGame() { Audio::instance().init(); // Discover available tracks (up to 100) and queue for background loading m_totalTracks = 0; + std::vector trackPaths; + trackPaths.reserve(100); for (int i = 1; i <= 100; ++i) { - char buf[128]; - std::snprintf(buf, sizeof(buf), "assets/music/music%03d.mp3", i); - // Use simple file existence check via std::filesystem - if (std::filesystem::exists(buf)) { - Audio::instance().addTrackAsync(buf); - ++m_totalTracks; - } else { + char base[128]; + 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); + } + m_totalTracks = static_cast(trackPaths.size()); + for (const auto& path : trackPaths) { + Audio::instance().addTrackAsync(path); } if (m_totalTracks > 0) { Audio::instance().startBackgroundLoading(); diff --git a/src/core/assets/AssetManager.cpp b/src/core/assets/AssetManager.cpp index be7b210..2ad7be8 100644 --- a/src/core/assets/AssetManager.cpp +++ b/src/core/assets/AssetManager.cpp @@ -208,27 +208,14 @@ bool AssetManager::loadSoundEffectWithFallback(const std::string& id, const std: return false; } - // Try WAV first, then MP3 fallback (matching main.cpp pattern) - std::string wavPath = "assets/music/" + baseName + ".wav"; - std::string mp3Path = "assets/music/" + baseName + ".mp3"; - - // Check WAV first - if (fileExists(wavPath)) { - if (m_soundSystem->loadSound(id, wavPath)) { - logInfo("Loaded sound effect: " + id + " from " + wavPath + " (WAV)"); - return true; - } + const std::string basePath = "assets/music/" + baseName; + std::string resolved = AssetPath::resolveWithExtensions(basePath, { ".wav", ".mp3" }); + if (!resolved.empty() && m_soundSystem->loadSound(id, resolved)) { + logInfo("Loaded sound effect: " + id + " from " + resolved); + return true; } - // Fallback to MP3 - if (fileExists(mp3Path)) { - if (m_soundSystem->loadSound(id, mp3Path)) { - logInfo("Loaded sound effect: " + id + " from " + mp3Path + " (MP3 fallback)"); - return true; - } - } - - setError("Failed to load sound effect: " + id + " (tried both WAV and MP3)"); + setError("Failed to load sound effect: " + id + " (no supported audio extension found)"); return false; } diff --git a/src/main.cpp b/src/main.cpp index aaf215e..65747c6 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -14,6 +14,9 @@ #include #include #include +#include +#include +#include #include "audio/Audio.h" #include "audio/SoundEffect.h" @@ -681,6 +684,25 @@ int main(int, char **) } 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); @@ -689,10 +711,13 @@ int main(int, char **) pixelFont.init("assets/fonts/PressStart2P-Regular.ttf", 16); ScoreManager scores; - // Load scores asynchronously to prevent startup hang due to network request - std::thread([&scores]() { + std::atomic scoresLoadComplete{false}; + // Load scores asynchronously but keep the worker alive until shutdown to avoid lifetime issues + std::jthread scoreLoader([&scores, &scoresLoadComplete]() { scores.load(); - }).detach(); + scoresLoadComplete.store(true, std::memory_order_release); + }); + std::jthread menuTrackLoader; Starfield starfield; starfield.init(200, LOGICAL_W, LOGICAL_H); Starfield3D starfield3D; @@ -751,9 +776,19 @@ int main(int, char **) // Initialize sound effects system SoundEffectManager::instance().init(); - - // Load sound effects - SoundEffectManager::instance().loadSound("clear_line", "assets/music/clear_line.wav"); + + 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 singleSounds = {"well_played", "smooth_clear", "great_move"}; @@ -769,49 +804,25 @@ int main(int, char **) appendVoices(tripleSounds); appendVoices(tetrisSounds); - // Helper function to load sound with WAV/MP3 fallback and file existence check - auto loadSoundWithFallback = [&](const std::string& id, const std::string& baseName) { - std::string wavPath = "assets/music/" + baseName + ".wav"; - std::string mp3Path = "assets/music/" + baseName + ".mp3"; - - // Check if WAV file exists first - SDL_IOStream* wavFile = SDL_IOFromFile(wavPath.c_str(), "rb"); - if (wavFile) { - SDL_CloseIO(wavFile); - if (SoundEffectManager::instance().loadSound(id, wavPath)) { - (void)0; - return; - } - } - - // Fallback to MP3 if WAV doesn't exist or fails to load - SDL_IOStream* mp3File = SDL_IOFromFile(mp3Path.c_str(), "rb"); - if (mp3File) { - SDL_CloseIO(mp3File); - if (SoundEffectManager::instance().loadSound(id, mp3Path)) { - (void)0; - return; - } - } - - SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "Failed to load sound: %s (tried both WAV and MP3)", id.c_str()); + auto loadVoice = [&](const std::string& id, const std::string& baseName) { + loadAudioAsset("assets/music/" + baseName, id); }; - - loadSoundWithFallback("nice_combo", "nice_combo"); - loadSoundWithFallback("you_fire", "you_fire"); - loadSoundWithFallback("well_played", "well_played"); - loadSoundWithFallback("keep_that_ryhtm", "keep_that_ryhtm"); - loadSoundWithFallback("great_move", "great_move"); - loadSoundWithFallback("smooth_clear", "smooth_clear"); - loadSoundWithFallback("impressive", "impressive"); - loadSoundWithFallback("triple_strike", "triple_strike"); - loadSoundWithFallback("amazing", "amazing"); - loadSoundWithFallback("you_re_unstoppable", "you_re_unstoppable"); - loadSoundWithFallback("boom_tetris", "boom_tetris"); - loadSoundWithFallback("wonderful", "wonderful"); - loadSoundWithFallback("lets_go", "lets_go"); // For level up - loadSoundWithFallback("hard_drop", "hard_drop_001"); - loadSoundWithFallback("new_level", "new_level"); + + 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; @@ -891,7 +902,7 @@ int main(int, char **) // Allow states to access the state manager for transitions ctx.stateManager = &stateMgr; ctx.game = &game; - ctx.scores = &scores; + ctx.scores = nullptr; // populated once async load finishes ctx.starfield = &starfield; ctx.starfield3D = &starfield3D; ctx.font = &font; @@ -925,6 +936,15 @@ int main(int, char **) 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; @@ -1010,6 +1030,10 @@ int main(int, char **) // 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); @@ -1102,6 +1126,7 @@ int main(int, char **) 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; @@ -1359,6 +1384,7 @@ int main(int, char **) SDL_StartTextInput(window); } else { isNewHighScore = false; + ensureScoresLoaded(); scores.submit(game.score(), game.lines(), game.level(), game.elapsed()); } state = AppState::GameOver; @@ -1376,25 +1402,21 @@ int main(int, char **) // Count actual music files first totalTracks = 0; - for (int i = 1; i <= 100; ++i) { // Check up to 100 files - char buf[64]; - std::snprintf(buf, sizeof(buf), "assets/music/music%03d.mp3", i); - - // Check if file exists - SDL_IOStream* file = SDL_IOFromFile(buf, "rb"); - if (file) { - SDL_CloseIO(file); - totalTracks++; - } else { - break; // No more consecutive files + std::vector 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); } - - // Add all found tracks to the background loading queue - for (int i = 1; i <= totalTracks; ++i) { - char buf[64]; - std::snprintf(buf, sizeof(buf), "assets/music/music%03d.mp3", i); - Audio::instance().addTrackAsync(buf); + totalTracks = static_cast(trackPaths.size()); + + for (const auto& track : trackPaths) { + Audio::instance().addTrackAsync(track); } // Start background loading thread @@ -1449,9 +1471,17 @@ int main(int, char **) // Load menu track once on first menu entry (in background to avoid blocking) static bool menuTrackLoaded = false; if (!menuTrackLoaded) { - std::thread([]() { - Audio::instance().setMenuTrack("assets/music/Every Block You Take.mp3"); - }).detach(); + 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; } @@ -1784,6 +1814,7 @@ int main(int, char **) // 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); @@ -1942,6 +1973,15 @@ int main(int, char **) // 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(); diff --git a/src/states/MenuState.cpp b/src/states/MenuState.cpp index 40572b7..11a6adc 100644 --- a/src/states/MenuState.cpp +++ b/src/states/MenuState.cpp @@ -9,6 +9,7 @@ #include #include #include +#include // Use dynamic logical dimensions from GlobalState instead of hardcoded values // This allows the UI to adapt when the window is resized or goes fullscreen @@ -239,7 +240,8 @@ void MenuState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect logi // High scores table with wave offset float scoresStartY = topPlayersY + 70; // more spacing under title - const auto &hs = ctx.scores ? ctx.scores->all() : *(new std::vector()); + static const std::vector EMPTY_SCORES; + const auto& hs = ctx.scores ? ctx.scores->all() : EMPTY_SCORES; size_t maxDisplay = std::min(hs.size(), size_t(12)); // Draw table header diff --git a/src/utils/ImagePathResolver.h b/src/utils/ImagePathResolver.h index 5fc442b..90c1f7f 100644 --- a/src/utils/ImagePathResolver.h +++ b/src/utils/ImagePathResolver.h @@ -2,17 +2,27 @@ #include #include +#include +#include #include namespace AssetPath { -inline bool fileExists(const std::string& path) { - if (path.empty()) { +inline std::string& baseDirectory() { + static std::string base; + return base; +} + +inline void setBasePath(std::string path) { + baseDirectory() = std::move(path); +} + +inline bool tryOpenFile(const std::string& candidate) { + if (candidate.empty()) { return false; } - - SDL_IOStream* file = SDL_IOFromFile(path.c_str(), "rb"); + SDL_IOStream* file = SDL_IOFromFile(candidate.c_str(), "rb"); if (file) { SDL_CloseIO(file); return true; @@ -20,13 +30,58 @@ inline bool fileExists(const std::string& path) { return false; } +inline bool fileExists(const std::string& path) { + if (path.empty()) { + return false; + } + + if (tryOpenFile(path)) { + return true; + } + + std::filesystem::path p(path); + if (!p.is_absolute()) { + const std::string& base = baseDirectory(); + if (!base.empty()) { + std::filesystem::path combined = std::filesystem::path(base) / p; + if (tryOpenFile(combined.string())) { + return true; + } + } + } + return false; +} + +inline std::string resolveWithBase(const std::string& path) { + if (path.empty()) { + return path; + } + + if (tryOpenFile(path)) { + return path; + } + + std::filesystem::path p(path); + if (!p.is_absolute()) { + const std::string& base = baseDirectory(); + if (!base.empty()) { + std::filesystem::path combined = std::filesystem::path(base) / p; + std::string combinedStr = combined.string(); + if (tryOpenFile(combinedStr)) { + return combinedStr; + } + } + } + return path; +} + inline std::string resolveImagePath(const std::string& originalPath) { if (originalPath.empty()) { return originalPath; } - if (fileExists(originalPath)) { - return originalPath; + if (auto resolved = resolveWithBase(originalPath); resolved != originalPath || fileExists(resolved)) { + return resolved; } const std::size_t dot = originalPath.find_last_of('.'); @@ -45,12 +100,41 @@ inline std::string resolveImagePath(const std::string& originalPath) { if (candidate == originalPath) { continue; } - if (fileExists(candidate)) { - return candidate; + std::string resolvedCandidate = resolveWithBase(candidate); + if (resolvedCandidate != candidate || fileExists(resolvedCandidate)) { + return resolvedCandidate; } } return originalPath; } +inline std::string resolveWithExtensions(const std::string& basePathWithoutExt, std::initializer_list extensions) { + for (const char* ext : extensions) { + std::string candidate = basePathWithoutExt + ext; + std::string resolved = resolveWithBase(candidate); + if (resolved != candidate || fileExists(resolved)) { + return resolved; + } + } + return {}; +} + +inline std::string resolveAudioPath(const std::string& basePathWithoutExt) { + static constexpr std::array AUDIO_EXTENSIONS = { + ".ogg", + ".flac", + ".wav", + ".mp3" + }; + for (const char* ext : AUDIO_EXTENSIONS) { + std::string candidate = basePathWithoutExt + ext; + std::string resolved = resolveWithBase(candidate); + if (resolved != candidate || fileExists(resolved)) { + return resolved; + } + } + return {}; +} + } // namespace AssetPath