diff --git a/CMakeLists.txt b/CMakeLists.txt
index 1e22b56..ba39b6f 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -61,7 +61,21 @@ set(TETRIS_SOURCES
)
if(APPLE)
- add_executable(tetris MACOSX_BUNDLE ${TETRIS_SOURCES})
+ set(APP_ICON "${CMAKE_SOURCE_DIR}/assets/favicon/AppIcon.icns")
+ if(EXISTS "${APP_ICON}")
+ add_executable(tetris MACOSX_BUNDLE ${TETRIS_SOURCES} "${APP_ICON}")
+ set_source_files_properties("${APP_ICON}" PROPERTIES MACOSX_PACKAGE_LOCATION "Resources")
+ set_target_properties(tetris PROPERTIES
+ MACOSX_BUNDLE_ICON_FILE "AppIcon"
+ MACOSX_BUNDLE_INFO_PLIST "${CMAKE_SOURCE_DIR}/cmake/MacBundleInfo.plist.in"
+ )
+ else()
+ message(WARNING "App icon not found at ${APP_ICON}; bundle will use default icon")
+ add_executable(tetris MACOSX_BUNDLE ${TETRIS_SOURCES})
+ set_target_properties(tetris PROPERTIES
+ MACOSX_BUNDLE_INFO_PLIST "${CMAKE_SOURCE_DIR}/cmake/MacBundleInfo.plist.in"
+ )
+ endif()
else()
add_executable(tetris ${TETRIS_SOURCES})
endif()
@@ -72,12 +86,6 @@ if (WIN32)
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")
@@ -132,6 +140,10 @@ target_link_libraries(tetris PRIVATE SDL3::SDL3 SDL3_ttf::SDL3_ttf SDL3_image::S
if (WIN32)
target_link_libraries(tetris PRIVATE mfplat mfreadwrite mfuuid)
endif()
+if(APPLE)
+ # Needed for MP3 decoding via AudioToolbox on macOS
+ target_link_libraries(tetris PRIVATE "-framework AudioToolbox" "-framework CoreFoundation")
+endif()
# Include production build configuration
include(cmake/ProductionBuild.cmake)
diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md
index 4913e93..5c66cfc 100644
--- a/DEPLOYMENT.md
+++ b/DEPLOYMENT.md
@@ -67,7 +67,7 @@ The distribution package includes:
### Development Environment
- **CMake** 3.20+
-- **Visual Studio 2022** (or compatible C++ compiler)
+- **Visual Studio 2026 (VS 18)** with Desktop development with C++ workload
- **vcpkg** package manager
- **SDL3** libraries (installed via vcpkg)
diff --git a/build-debug-and-run.ps1 b/build-debug-and-run.ps1
index 125d423..8fbcffc 100644
--- a/build-debug-and-run.ps1
+++ b/build-debug-and-run.ps1
@@ -8,12 +8,140 @@ Set-Location $root
Write-Host "Working directory: $PWD"
+# Require Visual Studio 18 (2026)
+function Find-VisualStudio {
+ $vswherePaths = @()
+ if ($env:ProgramFiles -ne $null) { $vswherePaths += Join-Path $env:ProgramFiles 'Microsoft Visual Studio\Installer\vswhere.exe' }
+ if (${env:ProgramFiles(x86)} -ne $null) { $vswherePaths += Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer\vswhere.exe' }
+
+ foreach ($p in $vswherePaths) {
+ if (Test-Path $p) { return @{Tool=$p} }
+ }
+
+ return $null
+}
+
+function Get-VS18 {
+ $fv = Find-VisualStudio
+ $vswhere = $null
+ if ($fv -and $fv.Tool) { $vswhere = $fv.Tool }
+ if ($vswhere) {
+ try {
+ $inst18 = & $vswhere -version "[18.0,19.0)" -products * -requires Microsoft.Component.MSBuild -property installationPath 2>$null
+ if ($inst18) {
+ $candidate = 'Visual Studio 18 2026'
+ $has = & cmake --help | Select-String -Pattern $candidate -SimpleMatch -Quiet
+ if ($has) {
+ $inst18Path = $inst18.Trim()
+ $vcvars = Join-Path $inst18Path 'VC\\Auxiliary\\Build\\vcvarsall.bat'
+ if (Test-Path $vcvars) { return @{Generator=$candidate; InstallPath=$inst18Path} }
+ Write-Error "Visual Studio 18 detected at $inst18Path but C++ toolchain (vcvarsall.bat) is missing. Install the Desktop development with C++ workload."
+ }
+ }
+ } catch {
+ # fall through to path checks below
+ }
+ }
+
+ # fallback: check common install locations (allow for non-standard drive)
+ $commonPaths = @(
+ 'D:\Program Files\Microsoft Visual Studio\2026\Community',
+ 'D:\Program Files\Microsoft Visual Studio\2026\Professional',
+ 'D:\Program Files\Microsoft Visual Studio\2026\Enterprise',
+ 'C:\Program Files\Microsoft Visual Studio\2026\Community',
+ 'C:\Program Files\Microsoft Visual Studio\2026\Professional',
+ 'C:\Program Files\Microsoft Visual Studio\2026\Enterprise'
+ )
+ foreach ($p in $commonPaths) {
+ if (Test-Path $p) {
+ $vcvars = Join-Path $p 'VC\\Auxiliary\\Build\\vcvarsall.bat'
+ if (Test-Path $vcvars) { return @{Generator='Visual Studio 18 2026'; InstallPath=$p} }
+ }
+ }
+
+ return $null
+}
+
+# Resolve vcpkg root/toolchain (prefer env, then C:\vcpkg, then repo-local)
+$vcpkgRoot = $env:VCPKG_ROOT
+if (-not $vcpkgRoot -or -not (Test-Path $vcpkgRoot)) {
+ if (Test-Path 'C:\vcpkg') {
+ $vcpkgRoot = 'C:\vcpkg'
+ $env:VCPKG_ROOT = $vcpkgRoot
+ } elseif (Test-Path (Join-Path $root 'vcpkg')) {
+ $vcpkgRoot = Join-Path $root 'vcpkg'
+ $env:VCPKG_ROOT = $vcpkgRoot
+ }
+}
+
+$toolchainFile = $null
+if ($vcpkgRoot) {
+ $candidateToolchain = Join-Path $vcpkgRoot 'scripts\buildsystems\vcpkg.cmake'
+ if (Test-Path $candidateToolchain) { $toolchainFile = $candidateToolchain }
+}
+
+# Determine VS18 generator and reconfigure build-msvc if needed
+$preferred = Get-VS18
+if ($preferred) {
+ Write-Host "Detected Visual Studio: $($preferred.Generator) at $($preferred.InstallPath)"
+} else {
+ Write-Error "Visual Studio 18 (2026) with Desktop development with C++ workload not found. Install VS 2026 and retry."
+ exit 1
+}
+
+if (-not $toolchainFile) {
+ Write-Error "vcpkg toolchain not found. Set VCPKG_ROOT or install vcpkg (expected scripts/buildsystems/vcpkg.cmake)."
+ exit 1
+}
+
+# If build-msvc exists, check CMakeCache generator
+$cacheFile = Join-Path $root 'build-msvc\CMakeCache.txt'
+$reconfigured = $false
+if (-not (Test-Path $cacheFile)) {
+ Write-Host "Configuring build-msvc with generator $($preferred.Generator) and vcpkg toolchain $toolchainFile"
+ & cmake -S . -B build-msvc -G "$($preferred.Generator)" -A x64 -DCMAKE_TOOLCHAIN_FILE="$toolchainFile" -DVCPKG_TARGET_TRIPLET=x64-windows
+ $cfgExit = $LASTEXITCODE
+ if ($cfgExit -ne 0) { Write-Error "CMake configure failed with exit code $cfgExit"; exit $cfgExit }
+ $reconfigured = $true
+} else {
+ $genLine = Get-Content $cacheFile | Select-String -Pattern 'CMAKE_GENERATOR:INTERNAL=' -SimpleMatch
+ $toolLine = Get-Content $cacheFile | Select-String -Pattern 'CMAKE_TOOLCHAIN_FILE:FILEPATH=' -SimpleMatch
+ $currentGen = $null
+ $currentTool = $null
+ if ($genLine) { $currentGen = $genLine -replace '.*CMAKE_GENERATOR:INTERNAL=(.*)','$1' }
+ if ($toolLine) { $currentTool = $toolLine -replace '.*CMAKE_TOOLCHAIN_FILE:FILEPATH=(.*)','$1' }
+ $needsReconfigure = $false
+ if ($currentGen -ne $preferred.Generator) { $needsReconfigure = $true }
+ if (-not $currentTool -or ($currentTool -ne $toolchainFile)) { $needsReconfigure = $true }
+
+ if ($needsReconfigure) {
+ Write-Host "Generator or toolchain changed; cleaning build-msvc directory for fresh configure."
+ Remove-Item -Path (Join-Path $root 'build-msvc') -Recurse -Force -ErrorAction SilentlyContinue
+ Write-Host "Configuring build-msvc with generator $($preferred.Generator) and toolchain $toolchainFile"
+ & cmake -S . -B build-msvc -G "$($preferred.Generator)" -A x64 -DCMAKE_TOOLCHAIN_FILE="$toolchainFile" -DVCPKG_TARGET_TRIPLET=x64-windows
+ $cfgExit = $LASTEXITCODE
+ if ($cfgExit -ne 0) { Write-Error "CMake configure failed with exit code $cfgExit"; exit $cfgExit }
+ $reconfigured = $true
+ } else {
+ Write-Host "CMake cache matches required generator and toolchain."
+ }
+}
+
# Build Debug configuration
Write-Host "Running: cmake --build build-msvc --config Debug"
-$proc = Start-Process -FilePath cmake -ArgumentList '--build','build-msvc','--config','Debug' -NoNewWindow -Wait -PassThru
-if ($proc.ExitCode -ne 0) {
- Write-Error "Build failed with exit code $($proc.ExitCode)"
- exit $proc.ExitCode
+& cmake --build build-msvc --config Debug
+$buildExit = $LASTEXITCODE
+if ($buildExit -ne 0) {
+ Write-Error "Build failed with exit code $buildExit"
+ $vcpkgLog = Join-Path $root 'build-msvc\vcpkg-manifest-install.log'
+ if (Test-Path $vcpkgLog) {
+ $log = Get-Content $vcpkgLog -Raw
+ if ($log -match 'Unable to find a valid Visual Studio instance') {
+ Write-Error "vcpkg could not locate a valid Visual Studio 18 toolchain. Install VS 2026 with Desktop development with C++ workload and retry."
+ }
+ }
+
+ exit $buildExit
}
if ($NoRun) {
diff --git a/build-production-mac.sh b/build-production-mac.sh
index 3ca9424..5cac243 100644
--- a/build-production-mac.sh
+++ b/build-production-mac.sh
@@ -13,6 +13,8 @@ VERSION="$(date +"%Y.%m.%d")"
CLEAN=0
PACKAGE_ONLY=0
PACKAGE_RUNTIME_DIR=""
+APP_ICON_SRC="assets/favicon/favicon-512x512.png"
+APP_ICON_ICNS="assets/favicon/AppIcon.icns"
print_usage() {
cat <<'USAGE'
@@ -68,6 +70,34 @@ configure_paths() {
PACKAGE_DIR="${OUTPUT_DIR}/TetrisGame-mac"
}
+generate_icns_if_needed() {
+ if [[ -f "$APP_ICON_ICNS" ]]; then
+ return
+ fi
+ if [[ ! -f "$APP_ICON_SRC" ]]; then
+ log WARN "Icon source PNG not found ($APP_ICON_SRC); skipping .icns generation"
+ return
+ fi
+ if ! command -v iconutil >/dev/null 2>&1; then
+ log WARN "iconutil not available; skipping .icns generation"
+ return
+ fi
+ log INFO "Generating AppIcon.icns from $APP_ICON_SRC ..."
+ tmpdir=$(mktemp -d)
+ iconset="$tmpdir/AppIcon.iconset"
+ mkdir -p "$iconset"
+ # Generate required sizes from 512 base; sips will downscale
+ for size in 16 32 64 128 256 512; do
+ sips -s format png "$APP_ICON_SRC" --resampleHeightWidth $size $size --out "$iconset/icon_${size}x${size}.png" >/dev/null
+ sips -s format png "$APP_ICON_SRC" --resampleHeightWidth $((size*2)) $((size*2)) --out "$iconset/icon_${size}x${size}@2x.png" >/dev/null
+ done
+ iconutil -c icns "$iconset" -o "$APP_ICON_ICNS" || log WARN "iconutil failed to create .icns"
+ rm -rf "$tmpdir"
+ if [[ -f "$APP_ICON_ICNS" ]]; then
+ log OK "Created $APP_ICON_ICNS"
+ fi
+}
+
clean_previous() {
if (( CLEAN )); then
log INFO "Cleaning previous build artifacts..."
@@ -296,6 +326,7 @@ main() {
log INFO "Output: $OUTPUT_DIR"
clean_previous
+ generate_icns_if_needed
configure_and_build
resolve_executable
prepare_package_dir
@@ -306,8 +337,32 @@ main() {
create_launchers
validate_package
create_zip
+ create_dmg
log INFO "Done. Package available at $PACKAGE_DIR"
}
+create_dmg() {
+ if [[ -z ${APP_BUNDLE_PATH:-} ]]; then
+ log INFO "No app bundle detected; skipping DMG creation"
+ return
+ fi
+
+ local app_name="${APP_BUNDLE_PATH##*/}"
+ local dmg_name="TetrisGame-mac-${VERSION}.dmg"
+ local dmg_path="$OUTPUT_DIR/$dmg_name"
+
+ if [[ ! -f "scripts/create-dmg.sh" ]]; then
+ log WARN "scripts/create-dmg.sh not found; skipping DMG creation"
+ return
+ fi
+
+ log INFO "Creating DMG installer: $dmg_path"
+ bash scripts/create-dmg.sh "$PACKAGE_DIR/$app_name" "$dmg_path" || log WARN "DMG creation failed"
+
+ if [[ -f "$dmg_path" ]]; then
+ log OK "DMG created: $dmg_path"
+ fi
+}
+
main "$@"
diff --git a/cmake/MacBundleInfo.plist.in b/cmake/MacBundleInfo.plist.in
index 2edadcf..73e760b 100644
--- a/cmake/MacBundleInfo.plist.in
+++ b/cmake/MacBundleInfo.plist.in
@@ -18,6 +18,8 @@
1.0
CFBundleVersion
1.0
+ CFBundleIconFile
+ AppIcon
LSMinimumSystemVersion
12.0
NSHighResolutionCapable
diff --git a/scripts/create-dmg.sh b/scripts/create-dmg.sh
new file mode 100644
index 0000000..9d3ac04
--- /dev/null
+++ b/scripts/create-dmg.sh
@@ -0,0 +1,72 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+# Create a distributable DMG for the macOS Tetris app
+# Usage: ./scripts/create-dmg.sh
+# Example: ./scripts/create-dmg.sh dist/TetrisGame-mac/tetris.app dist/TetrisGame.dmg
+
+if [[ $# -lt 2 ]]; then
+ echo "Usage: $0 "
+ echo "Example: $0 dist/TetrisGame-mac/tetris.app dist/TetrisGame.dmg"
+ exit 1
+fi
+
+APP_BUNDLE="$1"
+OUTPUT_DMG="$2"
+
+if [[ ! -d "$APP_BUNDLE" ]]; then
+ echo "Error: App bundle not found at $APP_BUNDLE" >&2
+ exit 1
+fi
+
+if [[ ! "$APP_BUNDLE" =~ \.app$ ]]; then
+ echo "Error: First argument must be a .app bundle" >&2
+ exit 1
+fi
+
+# Remove existing DMG if present
+rm -f "$OUTPUT_DMG"
+
+APP_NAME=$(basename "$APP_BUNDLE" .app)
+VOLUME_NAME="$APP_NAME"
+TEMP_DMG="${OUTPUT_DMG%.dmg}-temp.dmg"
+
+echo "[create-dmg] Creating temporary DMG..."
+# Create a temporary read-write DMG (generous size to fit the app + padding)
+hdiutil create -size 200m -fs HFS+ -volname "$VOLUME_NAME" "$TEMP_DMG"
+
+echo "[create-dmg] Mounting temporary DMG..."
+MOUNT_DIR=$(hdiutil attach "$TEMP_DMG" -nobrowse | grep "/Volumes/$VOLUME_NAME" | awk '{print $3}')
+
+if [[ -z "$MOUNT_DIR" ]]; then
+ echo "Error: Failed to mount temporary DMG" >&2
+ exit 1
+fi
+
+echo "[create-dmg] Copying app bundle to DMG..."
+cp -R "$APP_BUNDLE" "$MOUNT_DIR/"
+
+# Create Applications symlink for drag-and-drop installation
+echo "[create-dmg] Creating Applications symlink..."
+ln -s /Applications "$MOUNT_DIR/Applications"
+
+# Set custom icon if available
+VOLUME_ICON="$APP_BUNDLE/Contents/Resources/AppIcon.icns"
+if [[ -f "$VOLUME_ICON" ]]; then
+ echo "[create-dmg] Setting custom volume icon..."
+ cp "$VOLUME_ICON" "$MOUNT_DIR/.VolumeIcon.icns"
+ SetFile -c icnC "$MOUNT_DIR/.VolumeIcon.icns" 2>/dev/null || true
+fi
+
+# Unmount
+echo "[create-dmg] Ejecting temporary DMG..."
+hdiutil detach "$MOUNT_DIR"
+
+# Convert to compressed read-only DMG
+echo "[create-dmg] Converting to compressed DMG..."
+hdiutil convert "$TEMP_DMG" -format UDZO -o "$OUTPUT_DMG"
+
+# Cleanup
+rm -f "$TEMP_DMG"
+
+echo "[create-dmg] Created: $OUTPUT_DMG"
diff --git a/scripts/generate-mac-icon.sh b/scripts/generate-mac-icon.sh
new file mode 100644
index 0000000..331a320
--- /dev/null
+++ b/scripts/generate-mac-icon.sh
@@ -0,0 +1,44 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+if [[ $# -lt 2 ]]; then
+ echo "Usage: $0 "
+ exit 1
+fi
+
+ICON_SRC="$1"
+ICON_DEST="$2"
+
+if [[ -f "$ICON_DEST" ]]; then
+ exit 0
+fi
+
+if [[ ! -f "$ICON_SRC" ]]; then
+ echo "[generate-mac-icon] Source icon not found: $ICON_SRC" >&2
+ exit 1
+fi
+
+if ! command -v iconutil >/dev/null 2>&1; then
+ echo "[generate-mac-icon] iconutil not found" >&2
+ exit 1
+fi
+
+TMPDIR=$(mktemp -d)
+ICONSET="$TMPDIR/AppIcon.iconset"
+mkdir -p "$ICONSET"
+
+for SIZE in 16 32 64 128 256 512; do
+ sips -s format png "$ICON_SRC" --resampleHeightWidth $SIZE $SIZE --out "$ICONSET/icon_${SIZE}x${SIZE}.png" >/dev/null
+ sips -s format png "$ICON_SRC" --resampleHeightWidth $((SIZE * 2)) $((SIZE * 2)) --out "$ICONSET/icon_${SIZE}x${SIZE}@2x.png" >/dev/null
+done
+
+iconutil -c icns "$ICONSET" -o "$ICON_DEST"
+rm -rf "$TMPDIR"
+
+if [[ -f "$ICON_DEST" ]]; then
+ echo "[generate-mac-icon] Generated $ICON_DEST"
+else
+ echo "[generate-mac-icon] Failed to create $ICON_DEST" >&2
+ exit 1
+fi
diff --git a/src/audio/Audio.cpp b/src/audio/Audio.cpp
index ca666d0..d547585 100644
--- a/src/audio/Audio.cpp
+++ b/src/audio/Audio.cpp
@@ -26,6 +26,9 @@ using Microsoft::WRL::ComPtr;
#ifdef min
#undef min
#endif
+#elif defined(__APPLE__)
+#include
+#include
#endif
Audio& Audio::instance(){ static Audio inst; return inst; }
@@ -36,7 +39,7 @@ bool Audio::init(){ if(outSpec.freq!=0) return true; outSpec.format=SDL_AUDIO_S1
#endif
return true; }
-#ifdef _WIN32
+#if defined(_WIN32)
static bool decodeMP3(const std::string& path, std::vector& outPCM, int& outRate, int& outCh){
outPCM.clear(); outRate=44100; outCh=2;
ComPtr reader;
@@ -47,15 +50,85 @@ static bool decodeMP3(const std::string& path, std::vector& outPCM, int
reader->SetStreamSelection(MF_SOURCE_READER_FIRST_AUDIO_STREAM, TRUE);
while(true){ DWORD flags=0; ComPtr sample; if(FAILED(reader->ReadSample(MF_SOURCE_READER_FIRST_AUDIO_STREAM,0,nullptr,&flags,nullptr,&sample))) break; if(flags & MF_SOURCE_READERF_ENDOFSTREAM) break; if(!sample) continue; ComPtr buffer; if(FAILED(sample->ConvertToContiguousBuffer(&buffer))) continue; BYTE* data=nullptr; DWORD maxLen=0, curLen=0; if(SUCCEEDED(buffer->Lock(&data,&maxLen,&curLen)) && curLen){ size_t samples = curLen/2; size_t oldSz = outPCM.size(); outPCM.resize(oldSz + samples); std::memcpy(outPCM.data()+oldSz, data, curLen); } if(data) buffer->Unlock(); }
outRate=44100; outCh=2; return !outPCM.empty(); }
+#elif defined(__APPLE__)
+// Decode MP3 files using macOS AudioToolbox so music works on Apple builds.
+static bool decodeMP3(const std::string& path, std::vector& outPCM, int& outRate, int& outCh){
+ outPCM.clear();
+ outRate = 44100;
+ outCh = 2;
+
+ CFURLRef url = CFURLCreateFromFileSystemRepresentation(nullptr, reinterpret_cast(path.c_str()), path.size(), false);
+ if (!url) {
+ SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "[Audio] Failed to create URL for %s", path.c_str());
+ return false;
+ }
+
+ ExtAudioFileRef audioFile = nullptr;
+ OSStatus status = ExtAudioFileOpenURL(url, &audioFile);
+ CFRelease(url);
+ if (status != noErr || !audioFile) {
+ SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "[Audio] ExtAudioFileOpenURL failed (%d) for %s", static_cast(status), path.c_str());
+ return false;
+ }
+
+ AudioStreamBasicDescription clientFormat{};
+ clientFormat.mSampleRate = 44100.0;
+ clientFormat.mFormatID = kAudioFormatLinearPCM;
+ clientFormat.mFormatFlags = kAudioFormatFlagIsSignedInteger | kAudioFormatFlagIsPacked;
+ clientFormat.mBitsPerChannel = 16;
+ clientFormat.mChannelsPerFrame = 2;
+ clientFormat.mFramesPerPacket = 1;
+ clientFormat.mBytesPerFrame = (clientFormat.mBitsPerChannel / 8) * clientFormat.mChannelsPerFrame;
+ clientFormat.mBytesPerPacket = clientFormat.mBytesPerFrame * clientFormat.mFramesPerPacket;
+
+ status = ExtAudioFileSetProperty(audioFile, kExtAudioFileProperty_ClientDataFormat, sizeof(clientFormat), &clientFormat);
+ if (status != noErr) {
+ SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "[Audio] Failed to set client format (%d) for %s", static_cast(status), path.c_str());
+ ExtAudioFileDispose(audioFile);
+ return false;
+ }
+
+ const UInt32 framesPerBuffer = 4096;
+ std::vector buffer(framesPerBuffer * clientFormat.mChannelsPerFrame);
+ while (true) {
+ AudioBufferList abl{};
+ abl.mNumberBuffers = 1;
+ abl.mBuffers[0].mNumberChannels = clientFormat.mChannelsPerFrame;
+ abl.mBuffers[0].mDataByteSize = framesPerBuffer * clientFormat.mBytesPerFrame;
+ abl.mBuffers[0].mData = buffer.data();
+
+ UInt32 framesToRead = framesPerBuffer;
+ status = ExtAudioFileRead(audioFile, &framesToRead, &abl);
+ if (status != noErr) {
+ SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "[Audio] ExtAudioFileRead failed (%d) for %s", static_cast(status), path.c_str());
+ ExtAudioFileDispose(audioFile);
+ return false;
+ }
+
+ if (framesToRead == 0) {
+ break; // EOF
+ }
+
+ size_t samplesRead = static_cast(framesToRead) * clientFormat.mChannelsPerFrame;
+ outPCM.insert(outPCM.end(), buffer.data(), buffer.data() + samplesRead);
+ }
+
+ ExtAudioFileDispose(audioFile);
+ outRate = static_cast(clientFormat.mSampleRate);
+ outCh = static_cast(clientFormat.mChannelsPerFrame);
+ return !outPCM.empty();
+}
+#else
+static bool decodeMP3(const std::string& path, std::vector& outPCM, int& outRate, int& outCh){
+ (void)outPCM; (void)outRate; (void)outCh; (void)path;
+ SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "[Audio] MP3 unsupported on this platform: %s", path.c_str());
+ return false;
+}
#endif
void Audio::addTrack(const std::string& path){ AudioTrack t; t.path=path;
-#ifdef _WIN32
- if(decodeMP3(path, t.pcm, t.rate, t.channels)) t.ok=true; else SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "[Audio] Failed to decode %s", path.c_str());
-#else
- SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "[Audio] MP3 unsupported on this platform (stub): %s", path.c_str());
-#endif
- tracks.push_back(std::move(t)); }
+ if(decodeMP3(path, t.pcm, t.rate, t.channels)) t.ok=true; else SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "[Audio] Failed to decode %s", path.c_str());
+ tracks.push_back(std::move(t)); }
void Audio::shuffle(){
std::lock_guard lock(tracksMutex);
@@ -84,6 +157,25 @@ void Audio::start(){
playing = true;
}
+void Audio::skipToNextTrack(){
+ if(!ensureStream()) return;
+
+ // If menu music is active, just restart the looped menu track
+ if(isMenuMusic){
+ if(menuTrack.ok){
+ menuTrack.cursor = 0;
+ playing = true;
+ SDL_ResumeAudioStreamDevice(audioStream);
+ }
+ return;
+ }
+
+ if(tracks.empty()) return;
+ nextTrack();
+ playing = true;
+ SDL_ResumeAudioStreamDevice(audioStream);
+}
+
void Audio::toggleMute(){ muted=!muted; }
void Audio::setMuted(bool m){ muted=m; }
@@ -252,15 +344,11 @@ void Audio::backgroundLoadingThread() {
}
AudioTrack t;
t.path = path;
-#ifdef _WIN32
- if (mfInitialized && decodeMP3(path, t.pcm, t.rate, t.channels)) {
+ if (decodeMP3(path, t.pcm, t.rate, t.channels)) {
t.ok = true;
} else {
SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "[Audio] Failed to decode %s", path.c_str());
}
-#else
- SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "[Audio] MP3 unsupported on this platform (stub): %s", path.c_str());
-#endif
// Thread-safe addition to tracks
if (loadingAbort.load()) {
@@ -311,18 +399,11 @@ int Audio::getLoadedTrackCount() const {
void Audio::setMenuTrack(const std::string& path) {
menuTrack.path = path;
-#ifdef _WIN32
- // Ensure MF is started (might be redundant if init called, but safe)
- if(!mfStarted){ if(FAILED(MFStartup(MF_VERSION))) { SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "[Audio] MFStartup failed"); } else mfStarted=true; }
-
if (decodeMP3(path, menuTrack.pcm, menuTrack.rate, menuTrack.channels)) {
menuTrack.ok = true;
} else {
SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "[Audio] Failed to decode menu track %s", path.c_str());
}
-#else
- SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "[Audio] MP3 unsupported (stub): %s", path.c_str());
-#endif
}
void Audio::playMenuMusic() {
diff --git a/src/audio/Audio.h b/src/audio/Audio.h
index 0541d03..35f520a 100644
--- a/src/audio/Audio.h
+++ b/src/audio/Audio.h
@@ -42,6 +42,7 @@ public:
int getLoadedTrackCount() const; // get number of tracks loaded so far
void shuffle(); // randomize order
void start(); // begin playback
+ void skipToNextTrack(); // advance to the next music track
void toggleMute();
void setMuted(bool m);
bool isMuted() const { return muted; }
diff --git a/src/audio/SoundEffect.cpp b/src/audio/SoundEffect.cpp
index 24317bd..2a34b66 100644
--- a/src/audio/SoundEffect.cpp
+++ b/src/audio/SoundEffect.cpp
@@ -20,6 +20,9 @@
#pragma comment(lib, "mfuuid.lib")
#pragma comment(lib, "ole32.lib")
using Microsoft::WRL::ComPtr;
+#elif defined(__APPLE__)
+#include
+#include
#endif
// SoundEffect implementation
@@ -143,7 +146,7 @@ bool SoundEffect::loadWAV(const std::string& filePath) {
}
bool SoundEffect::loadMP3(const std::string& filePath) {
-#ifdef _WIN32
+#if defined(_WIN32)
static bool mfInitialized = false;
if (!mfInitialized) {
if (FAILED(MFStartup(MF_VERSION))) {
@@ -222,6 +225,67 @@ bool SoundEffect::loadMP3(const std::string& filePath) {
channels = 2;
sampleRate = 44100;
return true;
+#elif defined(__APPLE__)
+ CFURLRef url = CFURLCreateFromFileSystemRepresentation(nullptr, reinterpret_cast(filePath.c_str()), filePath.size(), false);
+ if (!url) {
+ SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "[SoundEffect] Failed to create URL for %s", filePath.c_str());
+ return false;
+ }
+
+ ExtAudioFileRef audioFile = nullptr;
+ OSStatus status = ExtAudioFileOpenURL(url, &audioFile);
+ CFRelease(url);
+ if (status != noErr || !audioFile) {
+ SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "[SoundEffect] ExtAudioFileOpenURL failed (%d) for %s", static_cast(status), filePath.c_str());
+ return false;
+ }
+
+ AudioStreamBasicDescription clientFormat{};
+ clientFormat.mSampleRate = 44100.0;
+ clientFormat.mFormatID = kAudioFormatLinearPCM;
+ clientFormat.mFormatFlags = kAudioFormatFlagIsSignedInteger | kAudioFormatFlagIsPacked;
+ clientFormat.mBitsPerChannel = 16;
+ clientFormat.mChannelsPerFrame = 2;
+ clientFormat.mFramesPerPacket = 1;
+ clientFormat.mBytesPerFrame = (clientFormat.mBitsPerChannel / 8) * clientFormat.mChannelsPerFrame;
+ clientFormat.mBytesPerPacket = clientFormat.mBytesPerFrame * clientFormat.mFramesPerPacket;
+
+ status = ExtAudioFileSetProperty(audioFile, kExtAudioFileProperty_ClientDataFormat, sizeof(clientFormat), &clientFormat);
+ if (status != noErr) {
+ SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "[SoundEffect] Failed to set client format (%d) for %s", static_cast(status), filePath.c_str());
+ ExtAudioFileDispose(audioFile);
+ return false;
+ }
+
+ const UInt32 framesPerBuffer = 2048;
+ std::vector buffer(framesPerBuffer * clientFormat.mChannelsPerFrame);
+ while (true) {
+ AudioBufferList abl{};
+ abl.mNumberBuffers = 1;
+ abl.mBuffers[0].mNumberChannels = clientFormat.mChannelsPerFrame;
+ abl.mBuffers[0].mDataByteSize = framesPerBuffer * clientFormat.mBytesPerFrame;
+ abl.mBuffers[0].mData = buffer.data();
+
+ UInt32 framesToRead = framesPerBuffer;
+ status = ExtAudioFileRead(audioFile, &framesToRead, &abl);
+ if (status != noErr) {
+ SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "[SoundEffect] ExtAudioFileRead failed (%d) for %s", static_cast(status), filePath.c_str());
+ ExtAudioFileDispose(audioFile);
+ return false;
+ }
+
+ if (framesToRead == 0) {
+ break; // EOF
+ }
+
+ size_t samplesRead = static_cast(framesToRead) * clientFormat.mChannelsPerFrame;
+ pcmData.insert(pcmData.end(), buffer.data(), buffer.data() + samplesRead);
+ }
+
+ ExtAudioFileDispose(audioFile);
+ channels = static_cast(clientFormat.mChannelsPerFrame);
+ sampleRate = static_cast(clientFormat.mSampleRate);
+ return !pcmData.empty();
#else
SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "[SoundEffect] MP3 support not available on this platform");
return false;
diff --git a/src/core/application/ApplicationManager.cpp b/src/core/application/ApplicationManager.cpp
index f626e04..30d8bc8 100644
--- a/src/core/application/ApplicationManager.cpp
+++ b/src/core/application/ApplicationManager.cpp
@@ -352,6 +352,16 @@ bool ApplicationManager::initializeManagers() {
consume = true;
}
+ // N: Skip to next song in the playlist (or restart menu track)
+ if (!consume && sc == SDL_SCANCODE_N) {
+ Audio::instance().skipToNextTrack();
+ if (!m_musicStarted && Audio::instance().getLoadedTrackCount() > 0) {
+ m_musicStarted = true;
+ m_musicEnabled = true;
+ }
+ consume = true;
+ }
+
if (!consume && sc == SDL_SCANCODE_H) {
AppState currentState = m_stateManager ? m_stateManager->getState() : AppState::Loading;
if (currentState != AppState::Loading) {
diff --git a/src/core/assets/AssetManager.cpp b/src/core/assets/AssetManager.cpp
index 2ad7be8..dc1d23d 100644
--- a/src/core/assets/AssetManager.cpp
+++ b/src/core/assets/AssetManager.cpp
@@ -131,14 +131,15 @@ bool AssetManager::loadFont(const std::string& id, const std::string& filepath,
}
// Create new font
+ std::string resolvedPath = AssetPath::resolveWithBase(filepath);
auto font = std::make_unique();
- if (!font->init(filepath, baseSize)) {
- setError("Failed to initialize font: " + filepath);
+ if (!font->init(resolvedPath, baseSize)) {
+ setError("Failed to initialize font: " + resolvedPath);
return false;
}
m_fonts[id] = std::move(font);
- logInfo("Loaded font: " + id + " from " + filepath + " (size: " + std::to_string(baseSize) + ")");
+ logInfo("Loaded font: " + id + " from " + resolvedPath + " (size: " + std::to_string(baseSize) + ")");
return true;
}
@@ -167,14 +168,16 @@ bool AssetManager::loadMusicTrack(const std::string& filepath) {
return false;
}
- if (!fileExists(filepath)) {
- setError("Music file not found: " + filepath);
+ std::string resolvedPath = AssetPath::resolveWithBase(filepath);
+
+ if (!fileExists(resolvedPath)) {
+ setError("Music file not found: " + resolvedPath);
return false;
}
try {
- m_audioSystem->addTrackAsync(filepath);
- logInfo("Added music track for loading: " + filepath);
+ m_audioSystem->addTrackAsync(resolvedPath);
+ logInfo("Added music track for loading: " + resolvedPath);
return true;
} catch (const std::exception& e) {
setError("Failed to add music track: " + std::string(e.what()));
@@ -188,16 +191,18 @@ bool AssetManager::loadSoundEffect(const std::string& id, const std::string& fil
return false;
}
- if (!fileExists(filepath)) {
- setError("Sound effect file not found: " + filepath);
+ std::string resolvedPath = AssetPath::resolveWithBase(filepath);
+
+ if (!fileExists(resolvedPath)) {
+ setError("Sound effect file not found: " + resolvedPath);
return false;
}
- if (m_soundSystem->loadSound(id, filepath)) {
- logInfo("Loaded sound effect: " + id + " from " + filepath);
+ if (m_soundSystem->loadSound(id, resolvedPath)) {
+ logInfo("Loaded sound effect: " + id + " from " + resolvedPath);
return true;
} else {
- setError("Failed to load sound effect: " + id + " from " + filepath);
+ setError("Failed to load sound effect: " + id + " from " + resolvedPath);
return false;
}
}
@@ -354,8 +359,9 @@ std::string AssetManager::getAssetPath(const std::string& relativePath) {
}
bool AssetManager::fileExists(const std::string& filepath) {
+ std::string resolved = AssetPath::resolveWithBase(filepath);
// Use SDL file I/O for consistency with main.cpp pattern
- SDL_IOStream* file = SDL_IOFromFile(filepath.c_str(), "rb");
+ SDL_IOStream* file = SDL_IOFromFile(resolved.c_str(), "rb");
if (file) {
SDL_CloseIO(file);
return true;
diff --git a/src/main.cpp b/src/main.cpp
index dfa928d..1971586 100644
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -610,11 +610,11 @@ int main(int, char **)
// Primary UI font (Orbitron) used for major UI text: buttons, loading, HUD
FontAtlas pixelFont;
- pixelFont.init("assets/fonts/Orbitron.ttf", 22);
+ pixelFont.init(AssetPath::resolveWithBase("assets/fonts/Orbitron.ttf"), 22);
// Secondary font (Exo2) used for longer descriptions, settings, credits
FontAtlas font;
- font.init("assets/fonts/Exo2.ttf", 20);
+ font.init(AssetPath::resolveWithBase("assets/fonts/Exo2.ttf"), 20);
ScoreManager scores;
std::atomic scoresLoadComplete{false};
@@ -1033,6 +1033,15 @@ int main(int, char **)
musicEnabled = !musicEnabled;
Settings::instance().setMusicEnabled(musicEnabled);
}
+ if (e.key.scancode == SDL_SCANCODE_N)
+ {
+ Audio::instance().skipToNextTrack();
+ if (!musicStarted && Audio::instance().getLoadedTrackCount() > 0) {
+ musicStarted = true;
+ musicEnabled = true;
+ Settings::instance().setMusicEnabled(true);
+ }
+ }
if (e.key.scancode == SDL_SCANCODE_S)
{
// Toggle sound effects
diff --git a/src/utils/ImagePathResolver.h b/src/utils/ImagePathResolver.h
index 90c1f7f..75f72e8 100644
--- a/src/utils/ImagePathResolver.h
+++ b/src/utils/ImagePathResolver.h
@@ -70,6 +70,22 @@ inline std::string resolveWithBase(const std::string& path) {
if (tryOpenFile(combinedStr)) {
return combinedStr;
}
+
+#if defined(__APPLE__)
+ // When running from a macOS bundle, SDL_GetBasePath may point at
+ // Contents/Resources while assets are in Contents/MacOS. Search that
+ // sibling too so Finder launches find the packaged assets.
+ std::filesystem::path basePath(base);
+ auto contentsDir = basePath.parent_path();
+ if (contentsDir.filename() == "Resources") {
+ auto macosDir = contentsDir.parent_path() / "MacOS";
+ std::filesystem::path alt = macosDir / p;
+ std::string altStr = alt.string();
+ if (tryOpenFile(altStr)) {
+ return altStr;
+ }
+ }
+#endif
}
}
return path;