Merge branch 'feature/macVersion' into develop
This commit is contained in:
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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 "$@"
|
||||
|
||||
@ -18,6 +18,8 @@
|
||||
<string>1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>AppIcon</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>12.0</string>
|
||||
<key>NSHighResolutionCapable</key>
|
||||
|
||||
72
scripts/create-dmg.sh
Normal file
72
scripts/create-dmg.sh
Normal 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"
|
||||
44
scripts/generate-mac-icon.sh
Normal file
44
scripts/generate-mac-icon.sh
Normal 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
|
||||
@ -26,6 +26,9 @@ using Microsoft::WRL::ComPtr;
|
||||
#ifdef min
|
||||
#undef min
|
||||
#endif
|
||||
#elif defined(__APPLE__)
|
||||
#include <AudioToolbox/AudioToolbox.h>
|
||||
#include <CoreFoundation/CoreFoundation.h>
|
||||
#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<int16_t>& outPCM, int& outRate, int& outCh){
|
||||
outPCM.clear(); outRate=44100; outCh=2;
|
||||
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);
|
||||
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(); }
|
||||
#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
|
||||
|
||||
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<std::mutex> 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() {
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -20,6 +20,9 @@
|
||||
#pragma comment(lib, "mfuuid.lib")
|
||||
#pragma comment(lib, "ole32.lib")
|
||||
using Microsoft::WRL::ComPtr;
|
||||
#elif defined(__APPLE__)
|
||||
#include <AudioToolbox/AudioToolbox.h>
|
||||
#include <CoreFoundation/CoreFoundation.h>
|
||||
#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<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
|
||||
SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "[SoundEffect] MP3 support not available on this platform");
|
||||
return false;
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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<FontAtlas>();
|
||||
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;
|
||||
|
||||
13
src/main.cpp
13
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<bool> 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
|
||||
|
||||
@ -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;
|
||||
|
||||
Reference in New Issue
Block a user