Merge branch 'feature/macVersion' into develop

This commit is contained in:
2025-12-15 19:51:13 +01:00
14 changed files with 547 additions and 47 deletions

View File

@ -61,7 +61,21 @@ set(TETRIS_SOURCES
) )
if(APPLE) 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() else()
add_executable(tetris ${TETRIS_SOURCES}) add_executable(tetris ${TETRIS_SOURCES})
endif() endif()
@ -72,12 +86,6 @@ if (WIN32)
target_sources(tetris PRIVATE src/app_icon.rc) target_sources(tetris PRIVATE src/app_icon.rc)
endif() endif()
if(APPLE)
set_target_properties(tetris PROPERTIES
MACOSX_BUNDLE_INFO_PLIST "${CMAKE_SOURCE_DIR}/cmake/MacBundleInfo.plist.in"
)
endif()
if (WIN32) if (WIN32)
# Ensure favicon.ico is available in the build directory for the resource compiler # Ensure favicon.ico is available in the build directory for the resource compiler
set(FAVICON_SRC "${CMAKE_SOURCE_DIR}/assets/favicon/favicon.ico") 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) if (WIN32)
target_link_libraries(tetris PRIVATE mfplat mfreadwrite mfuuid) target_link_libraries(tetris PRIVATE mfplat mfreadwrite mfuuid)
endif() 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 production build configuration
include(cmake/ProductionBuild.cmake) include(cmake/ProductionBuild.cmake)

View File

@ -67,7 +67,7 @@ The distribution package includes:
### Development Environment ### Development Environment
- **CMake** 3.20+ - **CMake** 3.20+
- **Visual Studio 2022** (or compatible C++ compiler) - **Visual Studio 2026 (VS 18)** with Desktop development with C++ workload
- **vcpkg** package manager - **vcpkg** package manager
- **SDL3** libraries (installed via vcpkg) - **SDL3** libraries (installed via vcpkg)

View File

@ -8,12 +8,140 @@ Set-Location $root
Write-Host "Working directory: $PWD" 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 # Build Debug configuration
Write-Host "Running: cmake --build build-msvc --config Debug" Write-Host "Running: cmake --build build-msvc --config Debug"
$proc = Start-Process -FilePath cmake -ArgumentList '--build','build-msvc','--config','Debug' -NoNewWindow -Wait -PassThru & cmake --build build-msvc --config Debug
if ($proc.ExitCode -ne 0) { $buildExit = $LASTEXITCODE
Write-Error "Build failed with exit code $($proc.ExitCode)" if ($buildExit -ne 0) {
exit $proc.ExitCode 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) { if ($NoRun) {

View File

@ -13,6 +13,8 @@ VERSION="$(date +"%Y.%m.%d")"
CLEAN=0 CLEAN=0
PACKAGE_ONLY=0 PACKAGE_ONLY=0
PACKAGE_RUNTIME_DIR="" PACKAGE_RUNTIME_DIR=""
APP_ICON_SRC="assets/favicon/favicon-512x512.png"
APP_ICON_ICNS="assets/favicon/AppIcon.icns"
print_usage() { print_usage() {
cat <<'USAGE' cat <<'USAGE'
@ -68,6 +70,34 @@ configure_paths() {
PACKAGE_DIR="${OUTPUT_DIR}/TetrisGame-mac" 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() { clean_previous() {
if (( CLEAN )); then if (( CLEAN )); then
log INFO "Cleaning previous build artifacts..." log INFO "Cleaning previous build artifacts..."
@ -296,6 +326,7 @@ main() {
log INFO "Output: $OUTPUT_DIR" log INFO "Output: $OUTPUT_DIR"
clean_previous clean_previous
generate_icns_if_needed
configure_and_build configure_and_build
resolve_executable resolve_executable
prepare_package_dir prepare_package_dir
@ -306,8 +337,32 @@ main() {
create_launchers create_launchers
validate_package validate_package
create_zip create_zip
create_dmg
log INFO "Done. Package available at $PACKAGE_DIR" 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 "$@" main "$@"

View File

@ -18,6 +18,8 @@
<string>1.0</string> <string>1.0</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>1.0</string> <string>1.0</string>
<key>CFBundleIconFile</key>
<string>AppIcon</string>
<key>LSMinimumSystemVersion</key> <key>LSMinimumSystemVersion</key>
<string>12.0</string> <string>12.0</string>
<key>NSHighResolutionCapable</key> <key>NSHighResolutionCapable</key>

72
scripts/create-dmg.sh Normal file
View File

@ -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 <app-bundle-path> <output-dmg>
# Example: ./scripts/create-dmg.sh dist/TetrisGame-mac/tetris.app dist/TetrisGame.dmg
if [[ $# -lt 2 ]]; then
echo "Usage: $0 <app-bundle-path> <output-dmg>"
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"

View File

@ -0,0 +1,44 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ $# -lt 2 ]]; then
echo "Usage: $0 <source-png> <output-icns>"
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

View File

@ -26,6 +26,9 @@ using Microsoft::WRL::ComPtr;
#ifdef min #ifdef min
#undef min #undef min
#endif #endif
#elif defined(__APPLE__)
#include <AudioToolbox/AudioToolbox.h>
#include <CoreFoundation/CoreFoundation.h>
#endif #endif
Audio& Audio::instance(){ static Audio inst; return inst; } 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 #endif
return true; } return true; }
#ifdef _WIN32 #if defined(_WIN32)
static bool decodeMP3(const std::string& path, std::vector<int16_t>& outPCM, int& outRate, int& outCh){ static bool decodeMP3(const std::string& path, std::vector<int16_t>& outPCM, int& outRate, int& outCh){
outPCM.clear(); outRate=44100; outCh=2; outPCM.clear(); outRate=44100; outCh=2;
ComPtr<IMFSourceReader> reader; ComPtr<IMFSourceReader> reader;
@ -47,15 +50,85 @@ static bool decodeMP3(const std::string& path, std::vector<int16_t>& outPCM, int
reader->SetStreamSelection(MF_SOURCE_READER_FIRST_AUDIO_STREAM, TRUE); reader->SetStreamSelection(MF_SOURCE_READER_FIRST_AUDIO_STREAM, TRUE);
while(true){ DWORD flags=0; ComPtr<IMFSample> 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<IMFMediaBuffer> 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(); } while(true){ DWORD flags=0; ComPtr<IMFSample> 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<IMFMediaBuffer> 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(); } 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<int16_t>& outPCM, int& outRate, int& outCh){
outPCM.clear();
outRate = 44100;
outCh = 2;
CFURLRef url = CFURLCreateFromFileSystemRepresentation(nullptr, reinterpret_cast<const UInt8*>(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<int>(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<int>(status), path.c_str());
ExtAudioFileDispose(audioFile);
return false;
}
const UInt32 framesPerBuffer = 4096;
std::vector<int16_t> 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<int>(status), path.c_str());
ExtAudioFileDispose(audioFile);
return false;
}
if (framesToRead == 0) {
break; // EOF
}
size_t samplesRead = static_cast<size_t>(framesToRead) * clientFormat.mChannelsPerFrame;
outPCM.insert(outPCM.end(), buffer.data(), buffer.data() + samplesRead);
}
ExtAudioFileDispose(audioFile);
outRate = static_cast<int>(clientFormat.mSampleRate);
outCh = static_cast<int>(clientFormat.mChannelsPerFrame);
return !outPCM.empty();
}
#else
static bool decodeMP3(const std::string& path, std::vector<int16_t>& 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 #endif
void Audio::addTrack(const std::string& path){ AudioTrack t; t.path=path; 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());
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)); }
#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)); }
void Audio::shuffle(){ void Audio::shuffle(){
std::lock_guard<std::mutex> lock(tracksMutex); std::lock_guard<std::mutex> lock(tracksMutex);
@ -84,6 +157,25 @@ void Audio::start(){
playing = true; 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::toggleMute(){ muted=!muted; }
void Audio::setMuted(bool m){ muted=m; } void Audio::setMuted(bool m){ muted=m; }
@ -252,15 +344,11 @@ void Audio::backgroundLoadingThread() {
} }
AudioTrack t; AudioTrack t;
t.path = path; t.path = path;
#ifdef _WIN32 if (decodeMP3(path, t.pcm, t.rate, t.channels)) {
if (mfInitialized && decodeMP3(path, t.pcm, t.rate, t.channels)) {
t.ok = true; t.ok = true;
} else { } else {
SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "[Audio] Failed to decode %s", path.c_str()); 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 // Thread-safe addition to tracks
if (loadingAbort.load()) { if (loadingAbort.load()) {
@ -311,18 +399,11 @@ int Audio::getLoadedTrackCount() const {
void Audio::setMenuTrack(const std::string& path) { void Audio::setMenuTrack(const std::string& path) {
menuTrack.path = 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)) { if (decodeMP3(path, menuTrack.pcm, menuTrack.rate, menuTrack.channels)) {
menuTrack.ok = true; menuTrack.ok = true;
} else { } else {
SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "[Audio] Failed to decode menu track %s", path.c_str()); 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() { void Audio::playMenuMusic() {

View File

@ -42,6 +42,7 @@ public:
int getLoadedTrackCount() const; // get number of tracks loaded so far int getLoadedTrackCount() const; // get number of tracks loaded so far
void shuffle(); // randomize order void shuffle(); // randomize order
void start(); // begin playback void start(); // begin playback
void skipToNextTrack(); // advance to the next music track
void toggleMute(); void toggleMute();
void setMuted(bool m); void setMuted(bool m);
bool isMuted() const { return muted; } bool isMuted() const { return muted; }

View File

@ -20,6 +20,9 @@
#pragma comment(lib, "mfuuid.lib") #pragma comment(lib, "mfuuid.lib")
#pragma comment(lib, "ole32.lib") #pragma comment(lib, "ole32.lib")
using Microsoft::WRL::ComPtr; using Microsoft::WRL::ComPtr;
#elif defined(__APPLE__)
#include <AudioToolbox/AudioToolbox.h>
#include <CoreFoundation/CoreFoundation.h>
#endif #endif
// SoundEffect implementation // SoundEffect implementation
@ -143,7 +146,7 @@ bool SoundEffect::loadWAV(const std::string& filePath) {
} }
bool SoundEffect::loadMP3(const std::string& filePath) { bool SoundEffect::loadMP3(const std::string& filePath) {
#ifdef _WIN32 #if defined(_WIN32)
static bool mfInitialized = false; static bool mfInitialized = false;
if (!mfInitialized) { if (!mfInitialized) {
if (FAILED(MFStartup(MF_VERSION))) { if (FAILED(MFStartup(MF_VERSION))) {
@ -222,6 +225,67 @@ bool SoundEffect::loadMP3(const std::string& filePath) {
channels = 2; channels = 2;
sampleRate = 44100; sampleRate = 44100;
return true; return true;
#elif defined(__APPLE__)
CFURLRef url = CFURLCreateFromFileSystemRepresentation(nullptr, reinterpret_cast<const UInt8*>(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<int>(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<int>(status), filePath.c_str());
ExtAudioFileDispose(audioFile);
return false;
}
const UInt32 framesPerBuffer = 2048;
std::vector<int16_t> 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<int>(status), filePath.c_str());
ExtAudioFileDispose(audioFile);
return false;
}
if (framesToRead == 0) {
break; // EOF
}
size_t samplesRead = static_cast<size_t>(framesToRead) * clientFormat.mChannelsPerFrame;
pcmData.insert(pcmData.end(), buffer.data(), buffer.data() + samplesRead);
}
ExtAudioFileDispose(audioFile);
channels = static_cast<int>(clientFormat.mChannelsPerFrame);
sampleRate = static_cast<int>(clientFormat.mSampleRate);
return !pcmData.empty();
#else #else
SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "[SoundEffect] MP3 support not available on this platform"); SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "[SoundEffect] MP3 support not available on this platform");
return false; return false;

View File

@ -352,6 +352,16 @@ bool ApplicationManager::initializeManagers() {
consume = true; 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) { if (!consume && sc == SDL_SCANCODE_H) {
AppState currentState = m_stateManager ? m_stateManager->getState() : AppState::Loading; AppState currentState = m_stateManager ? m_stateManager->getState() : AppState::Loading;
if (currentState != AppState::Loading) { if (currentState != AppState::Loading) {

View File

@ -131,14 +131,15 @@ bool AssetManager::loadFont(const std::string& id, const std::string& filepath,
} }
// Create new font // Create new font
std::string resolvedPath = AssetPath::resolveWithBase(filepath);
auto font = std::make_unique<FontAtlas>(); auto font = std::make_unique<FontAtlas>();
if (!font->init(filepath, baseSize)) { if (!font->init(resolvedPath, baseSize)) {
setError("Failed to initialize font: " + filepath); setError("Failed to initialize font: " + resolvedPath);
return false; return false;
} }
m_fonts[id] = std::move(font); 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; return true;
} }
@ -167,14 +168,16 @@ bool AssetManager::loadMusicTrack(const std::string& filepath) {
return false; return false;
} }
if (!fileExists(filepath)) { std::string resolvedPath = AssetPath::resolveWithBase(filepath);
setError("Music file not found: " + filepath);
if (!fileExists(resolvedPath)) {
setError("Music file not found: " + resolvedPath);
return false; return false;
} }
try { try {
m_audioSystem->addTrackAsync(filepath); m_audioSystem->addTrackAsync(resolvedPath);
logInfo("Added music track for loading: " + filepath); logInfo("Added music track for loading: " + resolvedPath);
return true; return true;
} catch (const std::exception& e) { } catch (const std::exception& e) {
setError("Failed to add music track: " + std::string(e.what())); 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; return false;
} }
if (!fileExists(filepath)) { std::string resolvedPath = AssetPath::resolveWithBase(filepath);
setError("Sound effect file not found: " + filepath);
if (!fileExists(resolvedPath)) {
setError("Sound effect file not found: " + resolvedPath);
return false; return false;
} }
if (m_soundSystem->loadSound(id, filepath)) { if (m_soundSystem->loadSound(id, resolvedPath)) {
logInfo("Loaded sound effect: " + id + " from " + filepath); logInfo("Loaded sound effect: " + id + " from " + resolvedPath);
return true; return true;
} else { } else {
setError("Failed to load sound effect: " + id + " from " + filepath); setError("Failed to load sound effect: " + id + " from " + resolvedPath);
return false; return false;
} }
} }
@ -354,8 +359,9 @@ std::string AssetManager::getAssetPath(const std::string& relativePath) {
} }
bool AssetManager::fileExists(const std::string& filepath) { bool AssetManager::fileExists(const std::string& filepath) {
std::string resolved = AssetPath::resolveWithBase(filepath);
// Use SDL file I/O for consistency with main.cpp pattern // 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) { if (file) {
SDL_CloseIO(file); SDL_CloseIO(file);
return true; return true;

View File

@ -610,11 +610,11 @@ int main(int, char **)
// Primary UI font (Orbitron) used for major UI text: buttons, loading, HUD // Primary UI font (Orbitron) used for major UI text: buttons, loading, HUD
FontAtlas pixelFont; 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 // Secondary font (Exo2) used for longer descriptions, settings, credits
FontAtlas font; FontAtlas font;
font.init("assets/fonts/Exo2.ttf", 20); font.init(AssetPath::resolveWithBase("assets/fonts/Exo2.ttf"), 20);
ScoreManager scores; ScoreManager scores;
std::atomic<bool> scoresLoadComplete{false}; std::atomic<bool> scoresLoadComplete{false};
@ -1033,6 +1033,15 @@ int main(int, char **)
musicEnabled = !musicEnabled; musicEnabled = !musicEnabled;
Settings::instance().setMusicEnabled(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) if (e.key.scancode == SDL_SCANCODE_S)
{ {
// Toggle sound effects // Toggle sound effects

View File

@ -70,6 +70,22 @@ inline std::string resolveWithBase(const std::string& path) {
if (tryOpenFile(combinedStr)) { if (tryOpenFile(combinedStr)) {
return 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; return path;