Compare commits

...

6 Commits

Author SHA1 Message Date
Claude 25766959f1 Add FamilyAlbums family photo portal with Nextcloud integration
A PHP-based family photo album portal featuring:
- Public gallery with year/month filtering and search
- Mobile-responsive design with Tailwind CSS
- Comment system for family members
- Admin interface for album management
- Flat-file JSON database (no MySQL needed)
- CSRF protection and XSS prevention
- Rate limiting and honeypot spam protection
2025-12-25 09:57:51 +00:00
admin 5f949121bf Merge pull request #10 from metacube2/claude/psytrance-visualizer-swift-bFGOw
Build Psytrance Visualizer with Swift and Metal
2025-12-22 22:38:56 +01:00
Claude a22c238dc4 Add Psytrance Visualizer macOS app with Metal rendering
A complete audio-reactive visualizer for psytrance music featuring:

Audio Analysis (DSPEngine):
- FFT spectrum analysis via Accelerate/vDSP
- 64-band Mel spectrogram
- Sub-bass energy extraction (<100Hz)
- Automatic sidechain pump detection
- Harmonic-to-Noise ratio (HNR) calculation
- Peak/transient detection

8 Visualization Modes (Metal Shaders):
1. FFT Classic - Frequency spectrum bars with glow
2. Mel Spectrogram - Waterfall display
3. Sub-Bass - Pulsating rings
4. Sidechain Pump - Breathing zoom effect
5. Harmonic/Noise - Geometric vs chaotic particles
6. Mandelbrot - Audio-reactive fractal zoom
7. Tunnel Warp - Infinite tunnel with distortion
8. DMT Geometry - Sacred geometry patterns

Features:
- Selectable audio input device (BlackHole support)
- Configurable buffer size (512/1024)
- Reactivity slider for visual intensity
- Auto-hiding control panel
- Fullscreen support with keyboard shortcuts (1-8, F, ESC)
- Persistent settings via UserDefaults
- Psytrance-inspired neon/UV color palette
2025-12-22 21:36:45 +00:00
admin b607a9cd8a Merge pull request #9 from metacube2/claude/suitcase-arcade-game-6y4do
Add RollkofferSimulator iOS SpriteKit arcade game
2025-12-20 18:20:36 +01:00
Claude 9e501cc4e8 Add RollkofferSimulator iOS SpriteKit arcade game
Complete implementation of a 2D top-down arcade collector game where players
control a rolling suitcase through an airport, collecting good dogs and green
people while avoiding bad dogs and gray people.

Features:
- Touch & drag controls for suitcase movement
- Automatic scrolling airport floor with tile pattern
- 4 entity types: good dogs (small/big), bad dogs, green/gray humans
- Spawn system with configurable distribution rates
- Collision detection with visual feedback effects
- Score tracking with high score persistence
- 90-second time limit with 10 dogs + 5 humans goal
- 3 lives system with invincibility frames
- Menu, Game, GameOver, and Victory scenes
- German UI text (Created by Ingo K.)

Technical:
- iOS 15+ with SpriteKit framework
- Modular architecture with Nodes, Managers, Scenes
- Physics-based collision detection
- UserDefaults for score persistence
2025-12-19 16:47:49 +00:00
admin 8d4afcf0ad Merge pull request #8 from metacube2/claude/ft991a-remote-control-app-T356a
FT-991A Remote Control App for macOS
2025-12-18 12:01:52 +01:00
60 changed files with 9760 additions and 0 deletions
+120
View File
@@ -0,0 +1,120 @@
//
// AppDelegate.swift
// PsytranceVisualizer
//
// Application delegate handling app lifecycle
//
import AppKit
import AVFoundation
/// Application delegate
final class AppDelegate: NSObject, NSApplicationDelegate {
// MARK: - Properties
private var mainWindowController: MainWindowController?
// MARK: - App Lifecycle
func applicationDidFinishLaunching(_ notification: Notification) {
// Request microphone permission
requestMicrophonePermission()
// Create and show main window
mainWindowController = MainWindowController()
mainWindowController?.showWindow(nil)
mainWindowController?.window?.makeKeyAndOrderFront(nil)
// Activate the application
NSApp.activate(ignoringOtherApps: true)
print("[AppDelegate] Application launched")
}
func applicationWillTerminate(_ notification: Notification) {
// Save settings
SettingsManager.shared.saveNow()
print("[AppDelegate] Application terminating")
}
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
return true
}
func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool {
return true
}
// MARK: - Permissions
private func requestMicrophonePermission() {
switch AVCaptureDevice.authorizationStatus(for: .audio) {
case .authorized:
print("[AppDelegate] Microphone access already authorized")
case .notDetermined:
AVCaptureDevice.requestAccess(for: .audio) { granted in
if granted {
print("[AppDelegate] Microphone access granted")
} else {
print("[AppDelegate] Microphone access denied")
self.showMicrophonePermissionAlert()
}
}
case .denied, .restricted:
print("[AppDelegate] Microphone access denied or restricted")
showMicrophonePermissionAlert()
@unknown default:
break
}
}
private func showMicrophonePermissionAlert() {
DispatchQueue.main.async {
let alert = NSAlert()
alert.messageText = "Microphone Access Required"
alert.informativeText = "Psytrance Visualizer needs access to your audio input to visualize music. Please enable microphone access in System Preferences > Security & Privacy > Privacy > Microphone."
alert.alertStyle = .warning
alert.addButton(withTitle: "Open System Preferences")
alert.addButton(withTitle: "Cancel")
if alert.runModal() == .alertFirstButtonReturn {
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone") {
NSWorkspace.shared.open(url)
}
}
}
}
// MARK: - Menu Actions
@IBAction func showAbout(_ sender: Any) {
let alert = NSAlert()
alert.messageText = "Psytrance Visualizer"
alert.informativeText = """
An audio-reactive visualizer for psytrance music.
8 Visualization Modes:
1 - FFT Classic
2 - Mel Spectrogram
3 - Sub-Bass
4 - Sidechain Pump
5 - Harmonic/Noise
6 - Mandelbrot
7 - Tunnel Warp
8 - DMT Geometry
Keyboard Shortcuts:
1-8: Switch visualization mode
F: Toggle fullscreen
ESC: Exit fullscreen
Tip: Use a virtual audio device like BlackHole to route system audio.
"""
alert.alertStyle = .informational
alert.runModal()
}
}
@@ -0,0 +1,133 @@
//
// PsytranceVisualizerApp.swift
// PsytranceVisualizer
//
// Main application entry point
//
import AppKit
// MARK: - Main Entry Point
/// Application entry point
@main
struct PsytranceVisualizerApp {
static func main() {
// Create the application
let app = NSApplication.shared
// Set up the delegate
let delegate = AppDelegate()
app.delegate = delegate
// Set activation policy
app.setActivationPolicy(.regular)
// Create the main menu
setupMainMenu()
// Run the application
app.run()
}
/// Sets up the application's main menu
private static func setupMainMenu() {
let mainMenu = NSMenu()
// Application menu
let appMenuItem = NSMenuItem()
mainMenu.addItem(appMenuItem)
let appMenu = NSMenu()
appMenuItem.submenu = appMenu
appMenu.addItem(withTitle: "About Psytrance Visualizer",
action: #selector(AppDelegate.showAbout(_:)),
keyEquivalent: "")
appMenu.addItem(NSMenuItem.separator())
appMenu.addItem(withTitle: "Hide Psytrance Visualizer",
action: #selector(NSApplication.hide(_:)),
keyEquivalent: "h")
let hideOthersItem = appMenu.addItem(withTitle: "Hide Others",
action: #selector(NSApplication.hideOtherApplications(_:)),
keyEquivalent: "h")
hideOthersItem.keyEquivalentModifierMask = [.command, .option]
appMenu.addItem(withTitle: "Show All",
action: #selector(NSApplication.unhideAllApplications(_:)),
keyEquivalent: "")
appMenu.addItem(NSMenuItem.separator())
appMenu.addItem(withTitle: "Quit Psytrance Visualizer",
action: #selector(NSApplication.terminate(_:)),
keyEquivalent: "q")
// View menu
let viewMenuItem = NSMenuItem()
mainMenu.addItem(viewMenuItem)
let viewMenu = NSMenu(title: "View")
viewMenuItem.submenu = viewMenu
viewMenu.addItem(withTitle: "Toggle Fullscreen",
action: #selector(NSWindow.toggleFullScreen(_:)),
keyEquivalent: "f")
viewMenu.addItem(NSMenuItem.separator())
// Visualization mode submenu
let modesMenuItem = NSMenuItem(title: "Visualization Mode", action: nil, keyEquivalent: "")
let modesMenu = NSMenu()
for mode in VisualizationMode.allCases {
let item = NSMenuItem(title: mode.displayName,
action: nil,
keyEquivalent: mode.shortcut)
item.tag = mode.rawValue
modesMenu.addItem(item)
}
modesMenuItem.submenu = modesMenu
viewMenu.addItem(modesMenuItem)
// Window menu
let windowMenuItem = NSMenuItem()
mainMenu.addItem(windowMenuItem)
let windowMenu = NSMenu(title: "Window")
windowMenuItem.submenu = windowMenu
windowMenu.addItem(withTitle: "Minimize",
action: #selector(NSWindow.miniaturize(_:)),
keyEquivalent: "m")
windowMenu.addItem(withTitle: "Zoom",
action: #selector(NSWindow.zoom(_:)),
keyEquivalent: "")
windowMenu.addItem(NSMenuItem.separator())
windowMenu.addItem(withTitle: "Bring All to Front",
action: #selector(NSApplication.arrangeInFront(_:)),
keyEquivalent: "")
// Help menu
let helpMenuItem = NSMenuItem()
mainMenu.addItem(helpMenuItem)
let helpMenu = NSMenu(title: "Help")
helpMenuItem.submenu = helpMenu
helpMenu.addItem(withTitle: "Psytrance Visualizer Help",
action: #selector(AppDelegate.showAbout(_:)),
keyEquivalent: "?")
NSApp.mainMenu = mainMenu
NSApp.windowsMenu = windowMenu
NSApp.helpMenu = helpMenu
}
}
@@ -0,0 +1,357 @@
//
// AudioInputManager.swift
// PsytranceVisualizer
//
// Manages audio input devices and captures audio buffers
//
import AVFoundation
import CoreAudio
import Combine
/// Represents an audio input device
struct AudioDevice: Identifiable, Hashable {
let id: AudioDeviceID
let uid: String
let name: String
let manufacturer: String
let isInput: Bool
func hash(into hasher: inout Hasher) {
hasher.combine(uid)
}
static func == (lhs: AudioDevice, rhs: AudioDevice) -> Bool {
lhs.uid == rhs.uid
}
}
/// Manages audio input capture using AVAudioEngine
final class AudioInputManager: ObservableObject {
// MARK: - Published Properties
@Published private(set) var availableDevices: [AudioDevice] = []
@Published private(set) var selectedDevice: AudioDevice?
@Published private(set) var isRunning = false
@Published private(set) var currentBufferSize: Int = 1024
// MARK: - Audio Properties
private var audioEngine: AVAudioEngine?
private var inputNode: AVAudioInputNode?
private let sampleRate: Double = 44100.0
// MARK: - Callbacks
var onAudioBuffer: ((AVAudioPCMBuffer) -> Void)?
// MARK: - Private Properties
private var deviceListenerBlock: AudioObjectPropertyListenerBlock?
private let processingQueue = DispatchQueue(label: "com.psytrance.audio", qos: .userInteractive)
// MARK: - Initialization
init() {
refreshDeviceList()
setupDeviceChangeListener()
}
deinit {
stop()
removeDeviceChangeListener()
}
// MARK: - Public Methods
/// Returns list of available audio input devices
func getAvailableInputDevices() -> [AudioDevice] {
return availableDevices
}
/// Refreshes the list of available audio input devices
func refreshDeviceList() {
var propertyAddress = AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyDevices,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain
)
var dataSize: UInt32 = 0
var status = AudioObjectGetPropertyDataSize(
AudioObjectID(kAudioObjectSystemObject),
&propertyAddress,
0,
nil,
&dataSize
)
guard status == noErr else {
print("[AudioInputManager] Failed to get device list size: \(status)")
return
}
let deviceCount = Int(dataSize) / MemoryLayout<AudioDeviceID>.size
var deviceIDs = [AudioDeviceID](repeating: 0, count: deviceCount)
status = AudioObjectGetPropertyData(
AudioObjectID(kAudioObjectSystemObject),
&propertyAddress,
0,
nil,
&dataSize,
&deviceIDs
)
guard status == noErr else {
print("[AudioInputManager] Failed to get device list: \(status)")
return
}
var devices: [AudioDevice] = []
for deviceID in deviceIDs {
if let device = getDeviceInfo(deviceID: deviceID), device.isInput {
devices.append(device)
}
}
DispatchQueue.main.async {
self.availableDevices = devices
print("[AudioInputManager] Found \(devices.count) input devices")
}
}
/// Selects an audio input device by UID
func selectDevice(uid: String) {
guard let device = availableDevices.first(where: { $0.uid == uid }) else {
print("[AudioInputManager] Device not found: \(uid)")
return
}
let wasRunning = isRunning
if wasRunning {
stop()
}
selectedDevice = device
setSystemInputDevice(deviceID: device.id)
if wasRunning {
start()
}
print("[AudioInputManager] Selected device: \(device.name)")
}
/// Sets the buffer size (512 or 1024)
func setBufferSize(_ size: Int) {
guard [512, 1024].contains(size) else {
print("[AudioInputManager] Invalid buffer size: \(size)")
return
}
let wasRunning = isRunning
if wasRunning {
stop()
}
currentBufferSize = size
if wasRunning {
start()
}
print("[AudioInputManager] Buffer size set to: \(size)")
}
/// Starts audio capture
func start() {
guard !isRunning else { return }
do {
// Create new audio engine
audioEngine = AVAudioEngine()
guard let engine = audioEngine else { return }
inputNode = engine.inputNode
guard let inputNode = inputNode else {
print("[AudioInputManager] No input node available")
return
}
// Get the input format
let inputFormat = inputNode.outputFormat(forBus: 0)
print("[AudioInputManager] Input format: \(inputFormat)")
// Install tap on input node
let bufferSize = AVAudioFrameCount(currentBufferSize)
inputNode.installTap(onBus: 0, bufferSize: bufferSize, format: inputFormat) { [weak self] buffer, _ in
self?.processingQueue.async {
self?.onAudioBuffer?(buffer)
}
}
// Prepare and start the engine
engine.prepare()
try engine.start()
DispatchQueue.main.async {
self.isRunning = true
}
print("[AudioInputManager] Audio capture started")
} catch {
print("[AudioInputManager] Failed to start audio capture: \(error)")
}
}
/// Stops audio capture
func stop() {
guard isRunning else { return }
inputNode?.removeTap(onBus: 0)
audioEngine?.stop()
audioEngine = nil
inputNode = nil
DispatchQueue.main.async {
self.isRunning = false
}
print("[AudioInputManager] Audio capture stopped")
}
// MARK: - Private Methods
/// Gets device info for a specific device ID
private func getDeviceInfo(deviceID: AudioDeviceID) -> AudioDevice? {
// Check if device has input channels
var propertyAddress = AudioObjectPropertyAddress(
mSelector: kAudioDevicePropertyStreamConfiguration,
mScope: kAudioDevicePropertyScopeInput,
mElement: kAudioObjectPropertyElementMain
)
var dataSize: UInt32 = 0
var status = AudioObjectGetPropertyDataSize(deviceID, &propertyAddress, 0, nil, &dataSize)
guard status == noErr, dataSize > 0 else { return nil }
let bufferListPointer = UnsafeMutablePointer<AudioBufferList>.allocate(capacity: Int(dataSize))
defer { bufferListPointer.deallocate() }
status = AudioObjectGetPropertyData(deviceID, &propertyAddress, 0, nil, &dataSize, bufferListPointer)
guard status == noErr else { return nil }
let bufferList = UnsafeMutableAudioBufferListPointer(bufferListPointer)
var inputChannelCount: UInt32 = 0
for buffer in bufferList {
inputChannelCount += buffer.mNumberChannels
}
guard inputChannelCount > 0 else { return nil }
// Get device UID
var uid: CFString = "" as CFString
var uidSize = UInt32(MemoryLayout<CFString>.size)
propertyAddress.mSelector = kAudioDevicePropertyDeviceUID
propertyAddress.mScope = kAudioObjectPropertyScopeGlobal
status = AudioObjectGetPropertyData(deviceID, &propertyAddress, 0, nil, &uidSize, &uid)
guard status == noErr else { return nil }
// Get device name
var name: CFString = "" as CFString
var nameSize = UInt32(MemoryLayout<CFString>.size)
propertyAddress.mSelector = kAudioDevicePropertyDeviceNameCFString
status = AudioObjectGetPropertyData(deviceID, &propertyAddress, 0, nil, &nameSize, &name)
guard status == noErr else { return nil }
// Get manufacturer
var manufacturer: CFString = "" as CFString
var manufacturerSize = UInt32(MemoryLayout<CFString>.size)
propertyAddress.mSelector = kAudioDevicePropertyDeviceManufacturerCFString
AudioObjectGetPropertyData(deviceID, &propertyAddress, 0, nil, &manufacturerSize, &manufacturer)
return AudioDevice(
id: deviceID,
uid: uid as String,
name: name as String,
manufacturer: manufacturer as String,
isInput: true
)
}
/// Sets the system default input device
private func setSystemInputDevice(deviceID: AudioDeviceID) {
var deviceIDCopy = deviceID
var propertyAddress = AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyDefaultInputDevice,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain
)
let status = AudioObjectSetPropertyData(
AudioObjectID(kAudioObjectSystemObject),
&propertyAddress,
0,
nil,
UInt32(MemoryLayout<AudioDeviceID>.size),
&deviceIDCopy
)
if status != noErr {
print("[AudioInputManager] Failed to set input device: \(status)")
}
}
/// Sets up listener for device changes
private func setupDeviceChangeListener() {
var propertyAddress = AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyDevices,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain
)
deviceListenerBlock = { [weak self] _, _ in
DispatchQueue.main.async {
self?.refreshDeviceList()
}
}
if let block = deviceListenerBlock {
AudioObjectAddPropertyListenerBlock(
AudioObjectID(kAudioObjectSystemObject),
&propertyAddress,
DispatchQueue.main,
block
)
}
}
/// Removes device change listener
private func removeDeviceChangeListener() {
guard let block = deviceListenerBlock else { return }
var propertyAddress = AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyDevices,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain
)
AudioObjectRemovePropertyListenerBlock(
AudioObjectID(kAudioObjectSystemObject),
&propertyAddress,
DispatchQueue.main,
block
)
}
}
+468
View File
@@ -0,0 +1,468 @@
//
// DSPEngine.swift
// PsytranceVisualizer
//
// Digital Signal Processing engine for audio analysis
//
import Accelerate
import AVFoundation
/// DSP Engine for real-time audio analysis
final class DSPEngine {
// MARK: - Configuration
private let sampleRate: Float = 44100.0
private var fftSize: Int
private let melBandCount: Int = 64
private let subBassUpperFreq: Float = 100.0
private let historySize: Int = 128
// MARK: - FFT Setup
private var fftSetup: vDSP_DFT_Setup?
private var window: [Float]
private var realPart: [Float]
private var imagPart: [Float]
private var magnitudes: [Float]
// MARK: - Mel Filterbank
private var melFilterbank: [[Float]]
private var melOutput: [Float]
// MARK: - Analysis State
private var subBassHistory: [Float]
private var previousMagnitudes: [Float]
private var envelopeValue: Float = 0
private var previousEnvelope: Float = 0
private var pumpHistory: [Float]
private var lastPeakTime: Double = 0
private var peakThreshold: Float = 0.3
// MARK: - Reactivity
private var reactivity: Float = 0.5
private var smoothingFactor: Float = 0.3
// MARK: - Initialization
init(bufferSize: Int = 1024) {
self.fftSize = bufferSize
// Initialize FFT arrays
self.window = [Float](repeating: 0, count: fftSize)
self.realPart = [Float](repeating: 0, count: fftSize)
self.imagPart = [Float](repeating: 0, count: fftSize)
self.magnitudes = [Float](repeating: 0, count: fftSize / 2)
self.previousMagnitudes = [Float](repeating: 0, count: fftSize / 2)
// Initialize Mel arrays
self.melOutput = [Float](repeating: 0, count: melBandCount)
self.melFilterbank = []
// Initialize history arrays
self.subBassHistory = [Float](repeating: 0, count: historySize)
self.pumpHistory = [Float](repeating: 0, count: 64)
// Create Hann window
vDSP_hann_window(&window, vDSP_Length(fftSize), Int32(vDSP_HANN_NORM))
// Create FFT setup
fftSetup = vDSP_DFT_zop_CreateSetup(
nil,
vDSP_Length(fftSize),
.FORWARD
)
// Build Mel filterbank
buildMelFilterbank()
}
deinit {
if let setup = fftSetup {
vDSP_DFT_DestroySetup(setup)
}
}
// MARK: - Public Methods
/// Sets reactivity value (0.0 - 1.0)
func setReactivity(_ value: Float) {
reactivity = max(0.0, min(1.0, value))
// Adjust smoothing based on reactivity (higher reactivity = less smoothing)
smoothingFactor = 0.1 + (1.0 - reactivity) * 0.4
}
/// Reconfigures for new buffer size
func setBufferSize(_ size: Int) {
guard size != fftSize else { return }
fftSize = size
// Reinitialize arrays
window = [Float](repeating: 0, count: fftSize)
realPart = [Float](repeating: 0, count: fftSize)
imagPart = [Float](repeating: 0, count: fftSize)
magnitudes = [Float](repeating: 0, count: fftSize / 2)
previousMagnitudes = [Float](repeating: 0, count: fftSize / 2)
// Recreate window
vDSP_hann_window(&window, vDSP_Length(fftSize), Int32(vDSP_HANN_NORM))
// Recreate FFT setup
if let setup = fftSetup {
vDSP_DFT_DestroySetup(setup)
}
fftSetup = vDSP_DFT_zop_CreateSetup(nil, vDSP_Length(fftSize), .FORWARD)
// Rebuild filterbank
buildMelFilterbank()
}
/// Processes audio buffer and returns analysis data
func process(buffer: AVAudioPCMBuffer) -> AudioAnalysisData {
guard let channelData = buffer.floatChannelData else {
return .empty
}
let frameCount = Int(buffer.frameLength)
let channelCount = Int(buffer.format.channelCount)
// Extract stereo channels
var leftChannel = [Float](repeating: 0, count: frameCount)
var rightChannel = [Float](repeating: 0, count: frameCount)
if channelCount >= 1 {
leftChannel = Array(UnsafeBufferPointer(start: channelData[0], count: frameCount))
}
if channelCount >= 2 {
rightChannel = Array(UnsafeBufferPointer(start: channelData[1], count: frameCount))
} else {
rightChannel = leftChannel
}
// Mix to mono for analysis
var monoBuffer = [Float](repeating: 0, count: frameCount)
vDSP_vadd(leftChannel, 1, rightChannel, 1, &monoBuffer, 1, vDSP_Length(frameCount))
var half: Float = 0.5
vDSP_vsmul(monoBuffer, 1, &half, &monoBuffer, 1, vDSP_Length(frameCount))
// Calculate RMS
var rmsValue: Float = 0
vDSP_rmsqv(monoBuffer, 1, &rmsValue, vDSP_Length(frameCount))
// Perform FFT
let fftMagnitudes = performFFT(monoBuffer)
// Calculate Mel bands
let melBands = calculateMelBands(from: fftMagnitudes)
// Extract sub-bass
let subBassEnergy = calculateSubBassEnergy(from: fftMagnitudes)
// Update sub-bass history
subBassHistory.removeFirst()
subBassHistory.append(subBassEnergy)
// Calculate sidechain envelope and pump detection
let (envelope, pumpAmount, isPumping) = detectSidechainPump(subBassEnergy: subBassEnergy)
// Calculate HNR
let hnrRatio = calculateHNR(buffer: monoBuffer)
// Detect peaks/transients
let (isPeak, peakIntensity) = detectPeak(rms: rmsValue)
// Calculate spectral centroid
let spectralCentroid = calculateSpectralCentroid(magnitudes: fftMagnitudes)
return AudioAnalysisData(
fftMagnitudes: fftMagnitudes,
melBands: melBands,
subBassEnergy: subBassEnergy,
subBassHistory: subBassHistory,
sidechainEnvelope: envelope,
sidechainPumpAmount: pumpAmount,
isPumping: isPumping,
hnrRatio: hnrRatio,
isPeak: isPeak,
peakIntensity: peakIntensity,
leftChannel: leftChannel,
rightChannel: rightChannel,
spectralCentroid: spectralCentroid,
rmsLevel: rmsValue
)
}
// MARK: - FFT
private func performFFT(_ buffer: [Float]) -> [Float] {
guard let setup = fftSetup else { return magnitudes }
let count = min(buffer.count, fftSize)
// Apply window
var windowedBuffer = [Float](repeating: 0, count: fftSize)
for i in 0..<count {
windowedBuffer[i] = buffer[i] * window[i]
}
// Prepare for DFT (separate into real and imaginary)
for i in 0..<fftSize {
realPart[i] = windowedBuffer[i]
imagPart[i] = 0
}
// Perform DFT
var outputReal = [Float](repeating: 0, count: fftSize)
var outputImag = [Float](repeating: 0, count: fftSize)
vDSP_DFT_Execute(setup, realPart, imagPart, &outputReal, &outputImag)
// Calculate magnitudes
let halfSize = fftSize / 2
var newMagnitudes = [Float](repeating: 0, count: halfSize)
for i in 0..<halfSize {
let real = outputReal[i]
let imag = outputImag[i]
newMagnitudes[i] = sqrt(real * real + imag * imag) / Float(fftSize)
}
// Apply smoothing
for i in 0..<halfSize {
magnitudes[i] = magnitudes[i] * smoothingFactor + newMagnitudes[i] * (1.0 - smoothingFactor)
}
previousMagnitudes = magnitudes
return magnitudes
}
// MARK: - Mel Filterbank
private func buildMelFilterbank() {
let halfFFT = fftSize / 2
let nyquist = sampleRate / 2.0
// Mel scale conversion
func hzToMel(_ hz: Float) -> Float {
return 2595.0 * log10(1.0 + hz / 700.0)
}
func melToHz(_ mel: Float) -> Float {
return 700.0 * (pow(10.0, mel / 2595.0) - 1.0)
}
let melMin = hzToMel(20.0)
let melMax = hzToMel(nyquist)
// Create mel points
var melPoints = [Float](repeating: 0, count: melBandCount + 2)
for i in 0..<melBandCount + 2 {
melPoints[i] = melMin + Float(i) * (melMax - melMin) / Float(melBandCount + 1)
}
// Convert back to Hz
var hzPoints = melPoints.map { melToHz($0) }
// Convert to FFT bins
var binPoints = hzPoints.map { Int($0 / nyquist * Float(halfFFT)) }
// Build triangular filters
melFilterbank = []
for m in 1...melBandCount {
var filter = [Float](repeating: 0, count: halfFFT)
let startBin = binPoints[m - 1]
let centerBin = binPoints[m]
let endBin = binPoints[m + 1]
// Rising edge
for k in startBin..<centerBin {
if centerBin != startBin {
filter[k] = Float(k - startBin) / Float(centerBin - startBin)
}
}
// Falling edge
for k in centerBin..<endBin {
if endBin != centerBin {
filter[k] = Float(endBin - k) / Float(endBin - centerBin)
}
}
melFilterbank.append(filter)
}
}
private func calculateMelBands(from magnitudes: [Float]) -> [Float] {
var result = [Float](repeating: 0, count: melBandCount)
for (i, filter) in melFilterbank.enumerated() {
var sum: Float = 0
let count = min(filter.count, magnitudes.count)
for j in 0..<count {
sum += magnitudes[j] * filter[j]
}
// Apply logarithmic scaling
result[i] = log10(1.0 + sum * 10.0) / log10(11.0)
}
// Apply smoothing to mel output
for i in 0..<melBandCount {
melOutput[i] = melOutput[i] * smoothingFactor + result[i] * (1.0 - smoothingFactor)
}
return melOutput
}
// MARK: - Sub-Bass Analysis
private func calculateSubBassEnergy(from magnitudes: [Float]) -> Float {
let binFrequency = sampleRate / Float(fftSize)
let subBassBinCount = Int(subBassUpperFreq / binFrequency)
guard subBassBinCount > 0, magnitudes.count >= subBassBinCount else { return 0 }
var sum: Float = 0
for i in 0..<subBassBinCount {
sum += magnitudes[i] * magnitudes[i]
}
let rms = sqrt(sum / Float(subBassBinCount))
// Normalize and apply gain
let normalized = min(1.0, rms * 5.0 * (1.0 + reactivity))
return normalized
}
// MARK: - Sidechain Pump Detection
private func detectSidechainPump(subBassEnergy: Float) -> (envelope: Float, pumpAmount: Float, isPumping: Bool) {
// Envelope follower with fast attack, slow release
let attackTime: Float = 0.005 // 5ms attack
let releaseTime: Float = 0.15 // 150ms release
let attackCoeff = exp(-1.0 / (sampleRate * attackTime))
let releaseCoeff = exp(-1.0 / (sampleRate * releaseTime))
if subBassEnergy > envelopeValue {
envelopeValue = attackCoeff * envelopeValue + (1.0 - attackCoeff) * subBassEnergy
} else {
envelopeValue = releaseCoeff * envelopeValue + (1.0 - releaseCoeff) * subBassEnergy
}
// Update pump history
pumpHistory.removeFirst()
pumpHistory.append(envelopeValue)
// Analyze pump periodicity
var pumpAmount: Float = 0
var isPumping = false
// Look for characteristic pump pattern (rise and fall)
let derivative = envelopeValue - previousEnvelope
previousEnvelope = envelopeValue
// Detect pump by finding periodic envelope variations
if pumpHistory.count >= 32 {
let recent = Array(pumpHistory.suffix(32))
var variance: Float = 0
let mean = recent.reduce(0, +) / Float(recent.count)
for value in recent {
variance += (value - mean) * (value - mean)
}
variance /= Float(recent.count)
// Higher variance = more pumping
pumpAmount = min(1.0, sqrt(variance) * 4.0)
isPumping = pumpAmount > 0.3 && abs(derivative) > 0.02
}
return (envelopeValue, pumpAmount, isPumping)
}
// MARK: - HNR Calculation
private func calculateHNR(buffer: [Float]) -> Float {
// Use autocorrelation to estimate harmonicity
let frameSize = min(buffer.count, 512)
var autocorr = [Float](repeating: 0, count: frameSize)
// Compute autocorrelation
vDSP_conv(buffer, 1, buffer, 1, &autocorr, 1, vDSP_Length(frameSize), vDSP_Length(frameSize))
// Find the peak in autocorrelation (excluding lag 0)
let minLag = 20 // Minimum lag to avoid DC component
let maxLag = min(frameSize - 1, 400) // Maximum lag
guard maxLag > minLag else { return 0.5 }
var maxValue: Float = 0
var maxIndex: vDSP_Length = 0
let searchRange = Array(autocorr[minLag...maxLag])
vDSP_maxvi(searchRange, 1, &maxValue, &maxIndex, vDSP_Length(searchRange.count))
// Calculate HNR as ratio of peak to first value
let noiseFloor = autocorr.suffix(from: maxLag).reduce(0) { $0 + abs($1) } / Float(frameSize - maxLag)
let harmonicPower = maxValue
let noisePower = max(noiseFloor, 0.0001)
// Convert to 0-1 range
let hnr = harmonicPower / (harmonicPower + noisePower)
return max(0.0, min(1.0, hnr))
}
// MARK: - Peak Detection
private var previousRMS: Float = 0
private var rmsHistory: [Float] = Array(repeating: 0, count: 16)
private func detectPeak(rms: Float) -> (isPeak: Bool, intensity: Float) {
// Update history
rmsHistory.removeFirst()
rmsHistory.append(rms)
// Calculate moving average
let average = rmsHistory.reduce(0, +) / Float(rmsHistory.count)
// Detect sudden increase
let increase = rms - previousRMS
let threshold = average * (0.5 + reactivity * 0.5)
previousRMS = rms
let isPeak = increase > threshold && rms > average * 1.5
let intensity = isPeak ? min(1.0, increase / max(average, 0.01) * 2.0) : 0
return (isPeak, intensity)
}
// MARK: - Spectral Centroid
private func calculateSpectralCentroid(magnitudes: [Float]) -> Float {
var weightedSum: Float = 0
var sum: Float = 0
for (i, mag) in magnitudes.enumerated() {
weightedSum += Float(i) * mag
sum += mag
}
guard sum > 0 else { return 0.5 }
let centroid = weightedSum / sum
let normalized = centroid / Float(magnitudes.count)
return max(0.0, min(1.0, normalized))
}
}
@@ -0,0 +1,90 @@
//
// AppSettings.swift
// PsytranceVisualizer
//
// Persistent application settings
//
import Foundation
/// Application settings that are persisted between sessions
struct AppSettings: Codable {
/// Selected audio input device UID
var selectedAudioDeviceUID: String?
/// Audio buffer size (512 or 1024 samples)
var bufferSize: Int
/// Last used visualization mode (1-8)
var lastVisualizationMode: Int
/// Reactivity slider value (0.0 - 1.0)
var reactivity: Float
/// Whether app was in fullscreen mode
var isFullscreen: Bool
/// Last window frame (for restoration)
var windowFrame: CodableRect?
/// Volume/gain adjustment
var inputGain: Float
/// Whether to show FPS counter
var showFPS: Bool
/// Default settings
static var `default`: AppSettings {
AppSettings(
selectedAudioDeviceUID: nil,
bufferSize: 1024,
lastVisualizationMode: 1,
reactivity: 0.5,
isFullscreen: false,
windowFrame: nil,
inputGain: 1.0,
showFPS: false
)
}
/// Available buffer sizes
static let availableBufferSizes = [512, 1024]
/// Validates and clamps settings to valid ranges
mutating func validate() {
// Clamp buffer size to valid options
if !AppSettings.availableBufferSizes.contains(bufferSize) {
bufferSize = 1024
}
// Clamp visualization mode
if lastVisualizationMode < 1 || lastVisualizationMode > 8 {
lastVisualizationMode = 1
}
// Clamp reactivity
reactivity = max(0.0, min(1.0, reactivity))
// Clamp input gain
inputGain = max(0.0, min(2.0, inputGain))
}
}
/// Codable wrapper for CGRect
struct CodableRect: Codable {
var x: Double
var y: Double
var width: Double
var height: Double
init(from rect: CGRect) {
self.x = Double(rect.origin.x)
self.y = Double(rect.origin.y)
self.width = Double(rect.size.width)
self.height = Double(rect.size.height)
}
var cgRect: CGRect {
CGRect(x: x, y: y, width: width, height: height)
}
}
@@ -0,0 +1,111 @@
//
// AudioAnalysisData.swift
// PsytranceVisualizer
//
// Audio analysis data structure containing all DSP results
//
import Foundation
/// Contains all audio analysis data computed by DSPEngine
struct AudioAnalysisData {
// MARK: - FFT Data
/// Raw FFT magnitude spectrum
var fftMagnitudes: [Float]
// MARK: - Mel Spectrogram
/// 64 Mel frequency bands
var melBands: [Float]
// MARK: - Sub-Bass Analysis
/// RMS energy below 100Hz (0.0 - 1.0)
var subBassEnergy: Float
/// History buffer for time-based visualization
var subBassHistory: [Float]
// MARK: - Sidechain Detection
/// Current envelope follower value (0.0 - 1.0)
var sidechainEnvelope: Float
/// Detected pumping amount (0.0 - 1.0)
var sidechainPumpAmount: Float
/// Whether pump is currently active
var isPumping: Bool
// MARK: - Harmonic-to-Noise Ratio
/// HNR ratio (0.0 = noise, 1.0 = pure harmonic)
var hnrRatio: Float
// MARK: - Transient Detection
/// Whether a transient peak was detected
var isPeak: Bool
/// Intensity of the detected peak (0.0 - 1.0)
var peakIntensity: Float
// MARK: - Stereo Channels
/// Left channel samples
var leftChannel: [Float]
/// Right channel samples
var rightChannel: [Float]
// MARK: - Additional Analysis
/// Spectral centroid (brightness) normalized 0.0 - 1.0
var spectralCentroid: Float
/// Overall RMS level
var rmsLevel: Float
// MARK: - Initialization
/// Creates an empty AudioAnalysisData with default values
static var empty: AudioAnalysisData {
AudioAnalysisData(
fftMagnitudes: [],
melBands: Array(repeating: 0, count: 64),
subBassEnergy: 0,
subBassHistory: [],
sidechainEnvelope: 0,
sidechainPumpAmount: 0,
isPumping: false,
hnrRatio: 0.5,
isPeak: false,
peakIntensity: 0,
leftChannel: [],
rightChannel: [],
spectralCentroid: 0.5,
rmsLevel: 0
)
}
/// Creates AudioAnalysisData with specified FFT size
static func create(fftSize: Int) -> AudioAnalysisData {
AudioAnalysisData(
fftMagnitudes: Array(repeating: 0, count: fftSize / 2),
melBands: Array(repeating: 0, count: 64),
subBassEnergy: 0,
subBassHistory: Array(repeating: 0, count: 128),
sidechainEnvelope: 0,
sidechainPumpAmount: 0,
isPumping: false,
hnrRatio: 0.5,
isPeak: false,
peakIntensity: 0,
leftChannel: [],
rightChannel: [],
spectralCentroid: 0.5,
rmsLevel: 0
)
}
}
@@ -0,0 +1,109 @@
//
// VisualizationMode.swift
// PsytranceVisualizer
//
// Enumeration of all available visualization modes
//
import Foundation
/// Available visualization modes, accessible via keyboard shortcuts 1-8
enum VisualizationMode: Int, CaseIterable, Codable {
case fftClassic = 1
case melSpectrogram = 2
case subBass = 3
case sidechainPump = 4
case hnr = 5
case mandelbrot = 6
case tunnelWarp = 7
case dmtGeometry = 8
/// Display name for UI
var displayName: String {
switch self {
case .fftClassic:
return "FFT Classic"
case .melSpectrogram:
return "Mel Spektrogramm"
case .subBass:
return "Sub-Bass (<100Hz)"
case .sidechainPump:
return "Sidechain Pump"
case .hnr:
return "Harmonic/Noise"
case .mandelbrot:
return "Mandelbrot"
case .tunnelWarp:
return "Tunnel Warp"
case .dmtGeometry:
return "DMT Geometry"
}
}
/// Keyboard shortcut (1-8)
var shortcut: String {
return "\(self.rawValue)"
}
/// Metal shader function name
var shaderFunctionName: String {
switch self {
case .fftClassic:
return "fftClassicFragment"
case .melSpectrogram:
return "melSpectrogramFragment"
case .subBass:
return "subBassFragment"
case .sidechainPump:
return "sidechainPumpFragment"
case .hnr:
return "hnrFragment"
case .mandelbrot:
return "mandelbrotFragment"
case .tunnelWarp:
return "tunnelWarpFragment"
case .dmtGeometry:
return "dmtGeometryFragment"
}
}
/// Description of the visualization
var description: String {
switch self {
case .fftClassic:
return "Classic frequency spectrum bars with glow effects"
case .melSpectrogram:
return "64-band Mel spectrogram with scrolling waterfall display"
case .subBass:
return "Pulsating rings visualizing sub-bass energy below 100Hz"
case .sidechainPump:
return "Breathing zoom effect synchronized to sidechain pumping"
case .hnr:
return "Harmonic vs noise visualization with geometric shapes"
case .mandelbrot:
return "Audio-reactive Mandelbrot fractal with zoom and color cycling"
case .tunnelWarp:
return "Infinite tunnel effect with warp distortion"
case .dmtGeometry:
return "Sacred geometry patterns: Flower of Life, Metatron's Cube, Sri Yantra"
}
}
/// Creates mode from keyboard key code
static func fromKeyCode(_ keyCode: UInt16) -> VisualizationMode? {
// Key codes for 1-8 on US keyboard
let keyCodes: [UInt16: Int] = [
18: 1, // 1
19: 2, // 2
20: 3, // 3
21: 4, // 4
23: 5, // 5
22: 6, // 6
26: 7, // 7
28: 8 // 8
]
guard let modeNumber = keyCodes[keyCode] else { return nil }
return VisualizationMode(rawValue: modeNumber)
}
}
+41
View File
@@ -0,0 +1,41 @@
// swift-tools-version: 5.9
// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription
let package = Package(
name: "PsytranceVisualizer",
platforms: [
.macOS(.v13)
],
products: [
.executable(
name: "PsytranceVisualizer",
targets: ["PsytranceVisualizer"]
)
],
targets: [
.executableTarget(
name: "PsytranceVisualizer",
path: ".",
exclude: [
"Package.swift",
"README.md"
],
sources: [
"App",
"Audio",
"Models",
"Rendering",
"UI",
"Utilities"
],
resources: [
.process("Resources")
],
swiftSettings: [
.unsafeFlags(["-enable-bare-slash-regex"])
]
)
]
)
@@ -0,0 +1,465 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 56;
objects = {
/* Begin PBXBuildFile section */
1100000000000001 /* PsytranceVisualizerApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000001 /* PsytranceVisualizerApp.swift */; };
1100000000000002 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000002 /* AppDelegate.swift */; };
1100000000000003 /* AudioInputManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000003 /* AudioInputManager.swift */; };
1100000000000004 /* DSPEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000004 /* DSPEngine.swift */; };
1100000000000005 /* AudioAnalysisData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000005 /* AudioAnalysisData.swift */; };
1100000000000006 /* VisualizationMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000006 /* VisualizationMode.swift */; };
1100000000000007 /* AppSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000007 /* AppSettings.swift */; };
1100000000000008 /* MetalRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000008 /* MetalRenderer.swift */; };
1100000000000009 /* Common.metal in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000009 /* Common.metal */; };
1100000000000010 /* FFTClassicShader.metal in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000010 /* FFTClassicShader.metal */; };
1100000000000011 /* MelSpectrogramShader.metal in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000011 /* MelSpectrogramShader.metal */; };
1100000000000012 /* SubBassShader.metal in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000012 /* SubBassShader.metal */; };
1100000000000013 /* SidechainPumpShader.metal in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000013 /* SidechainPumpShader.metal */; };
1100000000000014 /* HNRShader.metal in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000014 /* HNRShader.metal */; };
1100000000000015 /* MandelbrotShader.metal in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000015 /* MandelbrotShader.metal */; };
1100000000000016 /* TunnelWarpShader.metal in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000016 /* TunnelWarpShader.metal */; };
1100000000000017 /* DMTGeometryShader.metal in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000017 /* DMTGeometryShader.metal */; };
1100000000000018 /* MainWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000018 /* MainWindow.swift */; };
1100000000000019 /* ControlPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000019 /* ControlPanel.swift */; };
1100000000000020 /* VisualizerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000020 /* VisualizerView.swift */; };
1100000000000021 /* SettingsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000021 /* SettingsManager.swift */; };
1100000000000022 /* ColorPalette.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000022 /* ColorPalette.swift */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
2000000000000001 /* PsytranceVisualizer.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = PsytranceVisualizer.app; sourceTree = BUILT_PRODUCTS_DIR; };
2100000000000001 /* PsytranceVisualizerApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PsytranceVisualizerApp.swift; sourceTree = "<group>"; };
2100000000000002 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
2100000000000003 /* AudioInputManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioInputManager.swift; sourceTree = "<group>"; };
2100000000000004 /* DSPEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DSPEngine.swift; sourceTree = "<group>"; };
2100000000000005 /* AudioAnalysisData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioAnalysisData.swift; sourceTree = "<group>"; };
2100000000000006 /* VisualizationMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisualizationMode.swift; sourceTree = "<group>"; };
2100000000000007 /* AppSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettings.swift; sourceTree = "<group>"; };
2100000000000008 /* MetalRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetalRenderer.swift; sourceTree = "<group>"; };
2100000000000009 /* Common.metal */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.metal; path = Common.metal; sourceTree = "<group>"; };
2100000000000010 /* FFTClassicShader.metal */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.metal; path = FFTClassicShader.metal; sourceTree = "<group>"; };
2100000000000011 /* MelSpectrogramShader.metal */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.metal; path = MelSpectrogramShader.metal; sourceTree = "<group>"; };
2100000000000012 /* SubBassShader.metal */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.metal; path = SubBassShader.metal; sourceTree = "<group>"; };
2100000000000013 /* SidechainPumpShader.metal */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.metal; path = SidechainPumpShader.metal; sourceTree = "<group>"; };
2100000000000014 /* HNRShader.metal */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.metal; path = HNRShader.metal; sourceTree = "<group>"; };
2100000000000015 /* MandelbrotShader.metal */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.metal; path = MandelbrotShader.metal; sourceTree = "<group>"; };
2100000000000016 /* TunnelWarpShader.metal */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.metal; path = TunnelWarpShader.metal; sourceTree = "<group>"; };
2100000000000017 /* DMTGeometryShader.metal */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.metal; path = DMTGeometryShader.metal; sourceTree = "<group>"; };
2100000000000018 /* MainWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainWindow.swift; sourceTree = "<group>"; };
2100000000000019 /* ControlPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlPanel.swift; sourceTree = "<group>"; };
2100000000000020 /* VisualizerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisualizerView.swift; sourceTree = "<group>"; };
2100000000000021 /* SettingsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsManager.swift; sourceTree = "<group>"; };
2100000000000022 /* ColorPalette.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorPalette.swift; sourceTree = "<group>"; };
2100000000000023 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
2100000000000024 /* PsytranceVisualizer.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = PsytranceVisualizer.entitlements; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
3000000000000001 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
4000000000000001 = {
isa = PBXGroup;
children = (
4000000000000002 /* App */,
4000000000000003 /* Audio */,
4000000000000004 /* Models */,
4000000000000005 /* Rendering */,
4000000000000007 /* UI */,
4000000000000008 /* Utilities */,
4000000000000009 /* Resources */,
4000000000000010 /* Products */,
);
sourceTree = "<group>";
};
4000000000000002 /* App */ = {
isa = PBXGroup;
children = (
2100000000000001 /* PsytranceVisualizerApp.swift */,
2100000000000002 /* AppDelegate.swift */,
);
path = App;
sourceTree = "<group>";
};
4000000000000003 /* Audio */ = {
isa = PBXGroup;
children = (
2100000000000003 /* AudioInputManager.swift */,
2100000000000004 /* DSPEngine.swift */,
);
path = Audio;
sourceTree = "<group>";
};
4000000000000004 /* Models */ = {
isa = PBXGroup;
children = (
2100000000000005 /* AudioAnalysisData.swift */,
2100000000000006 /* VisualizationMode.swift */,
2100000000000007 /* AppSettings.swift */,
);
path = Models;
sourceTree = "<group>";
};
4000000000000005 /* Rendering */ = {
isa = PBXGroup;
children = (
2100000000000008 /* MetalRenderer.swift */,
4000000000000006 /* Shaders */,
);
path = Rendering;
sourceTree = "<group>";
};
4000000000000006 /* Shaders */ = {
isa = PBXGroup;
children = (
2100000000000009 /* Common.metal */,
2100000000000010 /* FFTClassicShader.metal */,
2100000000000011 /* MelSpectrogramShader.metal */,
2100000000000012 /* SubBassShader.metal */,
2100000000000013 /* SidechainPumpShader.metal */,
2100000000000014 /* HNRShader.metal */,
2100000000000015 /* MandelbrotShader.metal */,
2100000000000016 /* TunnelWarpShader.metal */,
2100000000000017 /* DMTGeometryShader.metal */,
);
path = Shaders;
sourceTree = "<group>";
};
4000000000000007 /* UI */ = {
isa = PBXGroup;
children = (
2100000000000018 /* MainWindow.swift */,
2100000000000019 /* ControlPanel.swift */,
2100000000000020 /* VisualizerView.swift */,
);
path = UI;
sourceTree = "<group>";
};
4000000000000008 /* Utilities */ = {
isa = PBXGroup;
children = (
2100000000000021 /* SettingsManager.swift */,
2100000000000022 /* ColorPalette.swift */,
);
path = Utilities;
sourceTree = "<group>";
};
4000000000000009 /* Resources */ = {
isa = PBXGroup;
children = (
2100000000000023 /* Info.plist */,
2100000000000024 /* PsytranceVisualizer.entitlements */,
);
path = Resources;
sourceTree = "<group>";
};
4000000000000010 /* Products */ = {
isa = PBXGroup;
children = (
2000000000000001 /* PsytranceVisualizer.app */,
);
name = Products;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
5000000000000001 /* PsytranceVisualizer */ = {
isa = PBXNativeTarget;
buildConfigurationList = 6000000000000003 /* Build configuration list for PBXNativeTarget "PsytranceVisualizer" */;
buildPhases = (
5000000000000002 /* Sources */,
3000000000000001 /* Frameworks */,
5000000000000003 /* Resources */,
);
buildRules = (
);
dependencies = (
);
name = PsytranceVisualizer;
productName = PsytranceVisualizer;
productReference = 2000000000000001 /* PsytranceVisualizer.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
0000000000000001 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 1500;
LastUpgradeCheck = 1500;
TargetAttributes = {
5000000000000001 = {
CreatedOnToolsVersion = 15.0;
};
};
};
buildConfigurationList = 6000000000000001 /* Build configuration list for PBXProject "PsytranceVisualizer" */;
compatibilityVersion = "Xcode 14.0";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 4000000000000001;
productRefGroup = 4000000000000010 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
5000000000000001 /* PsytranceVisualizer */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
5000000000000003 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
5000000000000002 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
1100000000000001 /* PsytranceVisualizerApp.swift in Sources */,
1100000000000002 /* AppDelegate.swift in Sources */,
1100000000000003 /* AudioInputManager.swift in Sources */,
1100000000000004 /* DSPEngine.swift in Sources */,
1100000000000005 /* AudioAnalysisData.swift in Sources */,
1100000000000006 /* VisualizationMode.swift in Sources */,
1100000000000007 /* AppSettings.swift in Sources */,
1100000000000008 /* MetalRenderer.swift in Sources */,
1100000000000009 /* Common.metal in Sources */,
1100000000000010 /* FFTClassicShader.metal in Sources */,
1100000000000011 /* MelSpectrogramShader.metal in Sources */,
1100000000000012 /* SubBassShader.metal in Sources */,
1100000000000013 /* SidechainPumpShader.metal in Sources */,
1100000000000014 /* HNRShader.metal in Sources */,
1100000000000015 /* MandelbrotShader.metal in Sources */,
1100000000000016 /* TunnelWarpShader.metal in Sources */,
1100000000000017 /* DMTGeometryShader.metal in Sources */,
1100000000000018 /* MainWindow.swift in Sources */,
1100000000000019 /* ControlPanel.swift in Sources */,
1100000000000020 /* VisualizerView.swift in Sources */,
1100000000000021 /* SettingsManager.swift in Sources */,
1100000000000022 /* ColorPalette.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */
6100000000000001 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = macosx;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = Debug;
};
6100000000000002 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = macosx;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
};
name = Release;
};
6100000000000003 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = Resources/PsytranceVisualizer.entitlements;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
ENABLE_HARDENED_RUNTIME = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Resources/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.music";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Psytrance Visualizer needs access to your audio input to visualize music in real-time.";
INFOPLIST_KEY_NSPrincipalClass = NSApplication;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.psytrance.visualizer;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
};
name = Debug;
};
6100000000000004 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = Resources/PsytranceVisualizer.entitlements;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
ENABLE_HARDENED_RUNTIME = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Resources/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.music";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Psytrance Visualizer needs access to your audio input to visualize music in real-time.";
INFOPLIST_KEY_NSPrincipalClass = NSApplication;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.psytrance.visualizer;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
6000000000000001 /* Build configuration list for PBXProject "PsytranceVisualizer" */ = {
isa = XCConfigurationList;
buildConfigurations = (
6100000000000001 /* Debug */,
6100000000000002 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
6000000000000003 /* Build configuration list for PBXNativeTarget "PsytranceVisualizer" */ = {
isa = XCConfigurationList;
buildConfigurations = (
6100000000000003 /* Debug */,
6100000000000004 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 0000000000000001 /* Project object */;
}
@@ -0,0 +1,279 @@
//
// MetalRenderer.swift
// PsytranceVisualizer
//
// Metal-based renderer for all visualization modes
//
import MetalKit
import simd
/// Uniform data passed to all shaders
struct ShaderUniforms {
var time: Float
var resolution: SIMD2<Float>
var reactivity: Float
// Audio analysis data
var subBassEnergy: Float
var sidechainPump: Float
var sidechainEnvelope: Float
var hnrRatio: Float
var isPeak: Float
var peakIntensity: Float
var spectralCentroid: Float
var rmsLevel: Float
// Visualization mode (1-8)
var mode: Int32
// Padding for Metal alignment
var padding: SIMD2<Float> = .zero
}
/// Metal renderer managing all visualization shaders
final class MetalRenderer: NSObject, ObservableObject {
// MARK: - Properties
private let device: MTLDevice
private let commandQueue: MTLCommandQueue
private var pipelineStates: [VisualizationMode: MTLRenderPipelineState] = [:]
private var currentPipelineState: MTLRenderPipelineState?
@Published private(set) var currentMode: VisualizationMode = .fftClassic
// MARK: - Buffers
private var uniformBuffer: MTLBuffer?
private var fftBuffer: MTLBuffer?
private var melBuffer: MTLBuffer?
private var subBassHistoryBuffer: MTLBuffer?
// MARK: - State
private var startTime: CFAbsoluteTime
private var uniforms = ShaderUniforms(
time: 0,
resolution: SIMD2<Float>(1920, 1080),
reactivity: 0.5,
subBassEnergy: 0,
sidechainPump: 0,
sidechainEnvelope: 0,
hnrRatio: 0.5,
isPeak: 0,
peakIntensity: 0,
spectralCentroid: 0.5,
rmsLevel: 0,
mode: 1
)
private var audioData: AudioAnalysisData = .empty
// MARK: - Constants
private let maxFFTSize = 1024
private let melBandCount = 64
private let historySize = 128
// MARK: - Initialization
init?(device: MTLDevice) {
guard let queue = device.makeCommandQueue() else {
print("[MetalRenderer] Failed to create command queue")
return nil
}
self.device = device
self.commandQueue = queue
self.startTime = CFAbsoluteTimeGetCurrent()
super.init()
createBuffers()
loadShaders()
}
// MARK: - Public Methods
/// Sets the current visualization mode
func setVisualizationMode(_ mode: VisualizationMode) {
currentMode = mode
currentPipelineState = pipelineStates[mode]
uniforms.mode = Int32(mode.rawValue)
print("[MetalRenderer] Mode changed to: \(mode.displayName)")
}
/// Updates audio analysis data
func updateAudioData(_ data: AudioAnalysisData) {
audioData = data
// Update uniforms
uniforms.subBassEnergy = data.subBassEnergy
uniforms.sidechainPump = data.sidechainPumpAmount
uniforms.sidechainEnvelope = data.sidechainEnvelope
uniforms.hnrRatio = data.hnrRatio
uniforms.isPeak = data.isPeak ? 1.0 : 0.0
uniforms.peakIntensity = data.peakIntensity
uniforms.spectralCentroid = data.spectralCentroid
uniforms.rmsLevel = data.rmsLevel
// Update FFT buffer
updateFFTBuffer(data.fftMagnitudes)
// Update Mel buffer
updateMelBuffer(data.melBands)
// Update sub-bass history buffer
updateSubBassHistoryBuffer(data.subBassHistory)
}
/// Sets reactivity value
func setReactivity(_ value: Float) {
uniforms.reactivity = max(0.0, min(1.0, value))
}
// MARK: - Private Methods
private func createBuffers() {
// Uniform buffer
uniformBuffer = device.makeBuffer(
length: MemoryLayout<ShaderUniforms>.stride,
options: .storageModeShared
)
// FFT magnitude buffer
fftBuffer = device.makeBuffer(
length: maxFFTSize * MemoryLayout<Float>.stride,
options: .storageModeShared
)
// Mel bands buffer
melBuffer = device.makeBuffer(
length: melBandCount * MemoryLayout<Float>.stride,
options: .storageModeShared
)
// Sub-bass history buffer
subBassHistoryBuffer = device.makeBuffer(
length: historySize * MemoryLayout<Float>.stride,
options: .storageModeShared
)
}
private func updateFFTBuffer(_ magnitudes: [Float]) {
guard let buffer = fftBuffer else { return }
let count = min(magnitudes.count, maxFFTSize)
memcpy(buffer.contents(), magnitudes, count * MemoryLayout<Float>.stride)
}
private func updateMelBuffer(_ bands: [Float]) {
guard let buffer = melBuffer else { return }
let count = min(bands.count, melBandCount)
memcpy(buffer.contents(), bands, count * MemoryLayout<Float>.stride)
}
private func updateSubBassHistoryBuffer(_ history: [Float]) {
guard let buffer = subBassHistoryBuffer else { return }
let count = min(history.count, historySize)
memcpy(buffer.contents(), history, count * MemoryLayout<Float>.stride)
}
private func loadShaders() {
guard let library = device.makeDefaultLibrary() else {
print("[MetalRenderer] Failed to load shader library")
return
}
// Load vertex shader (shared)
guard let vertexFunction = library.makeFunction(name: "vertexShader") else {
print("[MetalRenderer] Failed to load vertex shader")
return
}
// Load all fragment shaders
for mode in VisualizationMode.allCases {
guard let fragmentFunction = library.makeFunction(name: mode.shaderFunctionName) else {
print("[MetalRenderer] Failed to load shader: \(mode.shaderFunctionName)")
continue
}
let descriptor = MTLRenderPipelineDescriptor()
descriptor.vertexFunction = vertexFunction
descriptor.fragmentFunction = fragmentFunction
descriptor.colorAttachments[0].pixelFormat = .bgra8Unorm
// Enable blending for glow effects
descriptor.colorAttachments[0].isBlendingEnabled = true
descriptor.colorAttachments[0].sourceRGBBlendFactor = .sourceAlpha
descriptor.colorAttachments[0].destinationRGBBlendFactor = .oneMinusSourceAlpha
descriptor.colorAttachments[0].sourceAlphaBlendFactor = .one
descriptor.colorAttachments[0].destinationAlphaBlendFactor = .oneMinusSourceAlpha
do {
let pipelineState = try device.makeRenderPipelineState(descriptor: descriptor)
pipelineStates[mode] = pipelineState
print("[MetalRenderer] Loaded shader: \(mode.displayName)")
} catch {
print("[MetalRenderer] Failed to create pipeline state for \(mode.displayName): \(error)")
}
}
// Set initial pipeline state
currentPipelineState = pipelineStates[.fftClassic]
}
}
// MARK: - MTKViewDelegate
extension MetalRenderer: MTKViewDelegate {
func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) {
uniforms.resolution = SIMD2<Float>(Float(size.width), Float(size.height))
}
func draw(in view: MTKView) {
guard let pipelineState = currentPipelineState,
let drawable = view.currentDrawable,
let renderPassDescriptor = view.currentRenderPassDescriptor else {
return
}
// Update time
uniforms.time = Float(CFAbsoluteTimeGetCurrent() - startTime)
// Update uniform buffer
if let buffer = uniformBuffer {
memcpy(buffer.contents(), &uniforms, MemoryLayout<ShaderUniforms>.stride)
}
// Create command buffer
guard let commandBuffer = commandQueue.makeCommandBuffer(),
let renderEncoder = commandBuffer.makeRenderCommandEncoder(descriptor: renderPassDescriptor) else {
return
}
// Set pipeline state
renderEncoder.setRenderPipelineState(pipelineState)
// Set buffers
if let buffer = uniformBuffer {
renderEncoder.setFragmentBuffer(buffer, offset: 0, index: 0)
}
if let buffer = fftBuffer {
renderEncoder.setFragmentBuffer(buffer, offset: 0, index: 1)
}
if let buffer = melBuffer {
renderEncoder.setFragmentBuffer(buffer, offset: 0, index: 2)
}
if let buffer = subBassHistoryBuffer {
renderEncoder.setFragmentBuffer(buffer, offset: 0, index: 3)
}
// Draw fullscreen quad
renderEncoder.drawPrimitives(type: .triangleStrip, vertexStart: 0, vertexCount: 4)
renderEncoder.endEncoding()
commandBuffer.present(drawable)
commandBuffer.commit()
}
}
@@ -0,0 +1,241 @@
//
// Common.metal
// PsytranceVisualizer
//
// Shared shader functions, types, and psytrance color palette
//
#include <metal_stdlib>
using namespace metal;
// MARK: - Uniforms Structure
struct ShaderUniforms {
float time;
float2 resolution;
float reactivity;
float subBassEnergy;
float sidechainPump;
float sidechainEnvelope;
float hnrRatio;
float isPeak;
float peakIntensity;
float spectralCentroid;
float rmsLevel;
int mode;
float2 padding;
};
// MARK: - Vertex Data
struct VertexOut {
float4 position [[position]];
float2 uv;
};
// MARK: - Psytrance Color Palette
constant float3 neonMagenta = float3(1.0, 0.0, 1.0);
constant float3 neonCyan = float3(0.0, 1.0, 1.0);
constant float3 neonGreen = float3(0.224, 1.0, 0.078);
constant float3 uvViolet = float3(0.482, 0.0, 1.0);
constant float3 hotPink = float3(1.0, 0.2, 0.6);
constant float3 electricBlue = float3(0.0, 0.5, 1.0);
constant float3 deepPurple = float3(0.1, 0.0, 0.15);
// MARK: - Palette Functions
inline float3 getPaletteColor(int index) {
switch (index % 6) {
case 0: return neonMagenta;
case 1: return neonCyan;
case 2: return neonGreen;
case 3: return uvViolet;
case 4: return hotPink;
default: return electricBlue;
}
}
inline float3 rainbowPalette(float t) {
float3 a = float3(0.5, 0.5, 0.5);
float3 b = float3(0.5, 0.5, 0.5);
float3 c = float3(1.0, 1.0, 1.0);
float3 d = float3(0.0, 0.33, 0.67);
return a + b * cos(6.28318 * (c * t + d));
}
inline float3 psytrancePalette(float t, float time) {
// Cycle through psytrance colors
float phase = fract(t + time * 0.1);
if (phase < 0.2) {
return mix(uvViolet, neonMagenta, phase * 5.0);
} else if (phase < 0.4) {
return mix(neonMagenta, hotPink, (phase - 0.2) * 5.0);
} else if (phase < 0.6) {
return mix(hotPink, neonCyan, (phase - 0.4) * 5.0);
} else if (phase < 0.8) {
return mix(neonCyan, neonGreen, (phase - 0.6) * 5.0);
} else {
return mix(neonGreen, uvViolet, (phase - 0.8) * 5.0);
}
}
// MARK: - Heatmap for Spectrogram
inline float3 heatmap(float t) {
// Low energy: dark purple
// High energy: white through neon colors
if (t < 0.2) {
return mix(float3(0.05, 0.0, 0.1), uvViolet, t * 5.0);
} else if (t < 0.4) {
return mix(uvViolet, neonMagenta, (t - 0.2) * 5.0);
} else if (t < 0.6) {
return mix(neonMagenta, hotPink, (t - 0.4) * 5.0);
} else if (t < 0.8) {
return mix(hotPink, neonCyan, (t - 0.6) * 5.0);
} else {
return mix(neonCyan, float3(1.0), (t - 0.8) * 5.0);
}
}
// MARK: - Noise Functions
// Simplex-like noise
inline float hash(float2 p) {
float3 p3 = fract(float3(p.xyx) * 0.1031);
p3 += dot(p3, p3.yzx + 33.33);
return fract((p3.x + p3.y) * p3.z);
}
inline float noise(float2 p) {
float2 i = floor(p);
float2 f = fract(p);
f = f * f * (3.0 - 2.0 * f);
float a = hash(i);
float b = hash(i + float2(1.0, 0.0));
float c = hash(i + float2(0.0, 1.0));
float d = hash(i + float2(1.0, 1.0));
return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
}
inline float fbm(float2 p, int octaves) {
float value = 0.0;
float amplitude = 0.5;
float frequency = 1.0;
for (int i = 0; i < octaves; i++) {
value += amplitude * noise(p * frequency);
frequency *= 2.0;
amplitude *= 0.5;
}
return value;
}
// 3D noise for volumetric effects
inline float noise3D(float3 p) {
float3 i = floor(p);
float3 f = fract(p);
f = f * f * (3.0 - 2.0 * f);
float2 uv = i.xy + float2(37.0, 17.0) * i.z;
float a = hash(uv);
float b = hash(uv + float2(1.0, 0.0));
float c = hash(uv + float2(0.0, 1.0));
float d = hash(uv + float2(1.0, 1.0));
float2 uv2 = uv + float2(37.0, 17.0);
float e = hash(uv2);
float ff = hash(uv2 + float2(1.0, 0.0));
float g = hash(uv2 + float2(0.0, 1.0));
float h = hash(uv2 + float2(1.0, 1.0));
float x1 = mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
float x2 = mix(mix(e, ff, f.x), mix(g, h, f.x), f.y);
return mix(x1, x2, f.z);
}
// MARK: - Utility Functions
inline float2 rotate(float2 p, float angle) {
float c = cos(angle);
float s = sin(angle);
return float2(p.x * c - p.y * s, p.x * s + p.y * c);
}
inline float map(float value, float inMin, float inMax, float outMin, float outMax) {
return outMin + (outMax - outMin) * (value - inMin) / (inMax - inMin);
}
inline float smoothstepEdge(float edge0, float edge1, float x) {
float t = clamp((x - edge0) / (edge1 - edge0), 0.0, 1.0);
return t * t * (3.0 - 2.0 * t);
}
// MARK: - Glow Effect
inline float3 addGlow(float3 color, float intensity, float3 glowColor) {
return color + glowColor * intensity * intensity;
}
// MARK: - SDF Functions for Geometry
inline float sdCircle(float2 p, float r) {
return length(p) - r;
}
inline float sdBox(float2 p, float2 b) {
float2 d = abs(p) - b;
return length(max(d, 0.0)) + min(max(d.x, d.y), 0.0);
}
inline float sdHexagon(float2 p, float r) {
const float3 k = float3(-0.866025404, 0.5, 0.577350269);
p = abs(p);
p -= 2.0 * min(dot(k.xy, p), 0.0) * k.xy;
p -= float2(clamp(p.x, -k.z * r, k.z * r), r);
return length(p) * sign(p.y);
}
inline float sdStar(float2 p, float r, int n, float m) {
float an = 3.141593 / float(n);
float en = 3.141593 / m;
float2 acs = float2(cos(an), sin(an));
float2 ecs = float2(cos(en), sin(en));
float bn = fmod(atan2(p.x, p.y), 2.0 * an) - an;
p = length(p) * float2(cos(bn), abs(sin(bn)));
p -= r * acs;
p += ecs * clamp(-dot(p, ecs), 0.0, r * acs.y / ecs.y);
return length(p) * sign(p.x);
}
// MARK: - Vertex Shader (Fullscreen Quad)
vertex VertexOut vertexShader(uint vertexID [[vertex_id]]) {
// Generate fullscreen quad
float2 positions[4] = {
float2(-1.0, -1.0),
float2( 1.0, -1.0),
float2(-1.0, 1.0),
float2( 1.0, 1.0)
};
float2 uvs[4] = {
float2(0.0, 1.0),
float2(1.0, 1.0),
float2(0.0, 0.0),
float2(1.0, 0.0)
};
VertexOut out;
out.position = float4(positions[vertexID], 0.0, 1.0);
out.uv = uvs[vertexID];
return out;
}
@@ -0,0 +1,290 @@
//
// DMTGeometryShader.metal
// PsytranceVisualizer
//
// Sacred geometry patterns: Flower of Life, Metatron's Cube, Sri Yantra, Hexagonal
//
#include <metal_stdlib>
using namespace metal;
#include "Common.metal"
// === SACRED GEOMETRY PRIMITIVES ===
// Flower of Life - overlapping circles
float flowerOfLife(float2 p, float scale, float time) {
p *= scale;
float result = 0.0;
float circleRadius = 0.5;
// Center circle
result = max(result, 1.0 - smoothstep(circleRadius - 0.02, circleRadius, length(p)));
// 6 circles around center
for (int i = 0; i < 6; i++) {
float angle = float(i) * 3.14159 / 3.0 + time * 0.1;
float2 offset = float2(cos(angle), sin(angle)) * circleRadius;
float d = length(p - offset);
result = max(result, 1.0 - smoothstep(circleRadius - 0.02, circleRadius, d));
}
// Second ring of 12 circles
for (int i = 0; i < 12; i++) {
float angle = float(i) * 3.14159 / 6.0 + time * 0.05;
float2 offset = float2(cos(angle), sin(angle)) * circleRadius * 2.0;
float d = length(p - offset);
result = max(result, 0.5 * (1.0 - smoothstep(circleRadius - 0.02, circleRadius, d)));
}
return result;
}
// Metatron's Cube - 13 circles with connecting lines
float metatronsCube(float2 p, float scale, float time) {
p *= scale;
float result = 0.0;
float nodeRadius = 0.08;
float lineWidth = 0.01;
// Define the 13 points of Metatron's Cube
float2 points[13];
points[0] = float2(0.0, 0.0); // Center
// Inner hexagon
for (int i = 0; i < 6; i++) {
float angle = float(i) * 3.14159 / 3.0 + time * 0.1;
points[i + 1] = float2(cos(angle), sin(angle)) * 0.5;
}
// Outer hexagon (rotated)
for (int i = 0; i < 6; i++) {
float angle = float(i) * 3.14159 / 3.0 + 3.14159 / 6.0 + time * 0.1;
points[i + 7] = float2(cos(angle), sin(angle)) * 0.866;
}
// Draw nodes
for (int i = 0; i < 13; i++) {
float d = length(p - points[i]);
float node = 1.0 - smoothstep(nodeRadius - 0.01, nodeRadius, d);
result = max(result, node);
}
// Draw connecting lines
for (int i = 0; i < 13; i++) {
for (int j = i + 1; j < 13; j++) {
float2 a = points[i];
float2 b = points[j];
float2 pa = p - a;
float2 ba = b - a;
float t = clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0);
float d = length(pa - ba * t);
float line = 1.0 - smoothstep(lineWidth, lineWidth + 0.005, d);
result = max(result, line * 0.5);
}
}
return result;
}
// Sri Yantra - 9 interlocking triangles
float sriYantra(float2 p, float scale, float time) {
p *= scale;
float result = 0.0;
float lineWidth = 0.015;
// Rotating factor
float rot = time * 0.05;
// Draw 4 upward triangles
for (int i = 0; i < 4; i++) {
float size = 0.3 + float(i) * 0.15;
float yOffset = -0.1 + float(i) * 0.05;
float2 tp = p - float2(0.0, yOffset);
tp = rotate(tp, rot);
// Triangle SDF
float2 a = float2(0.0, size);
float2 b = float2(-size * 0.866, -size * 0.5);
float2 c = float2(size * 0.866, -size * 0.5);
float d1 = dot(tp - a, normalize(float2(b.y - a.y, a.x - b.x)));
float d2 = dot(tp - b, normalize(float2(c.y - b.y, b.x - c.x)));
float d3 = dot(tp - c, normalize(float2(a.y - c.y, c.x - a.x)));
float triangleDist = max(max(d1, d2), d3);
float edge = 1.0 - smoothstep(0.0, lineWidth, abs(triangleDist));
result = max(result, edge * (1.0 - float(i) * 0.15));
}
// Draw 5 downward triangles
for (int i = 0; i < 5; i++) {
float size = 0.25 + float(i) * 0.12;
float yOffset = 0.1 - float(i) * 0.04;
float2 tp = p - float2(0.0, yOffset);
tp = rotate(tp, -rot);
float2 a = float2(0.0, -size);
float2 b = float2(-size * 0.866, size * 0.5);
float2 c = float2(size * 0.866, size * 0.5);
float d1 = dot(tp - a, normalize(float2(b.y - a.y, a.x - b.x)));
float d2 = dot(tp - b, normalize(float2(c.y - b.y, b.x - c.x)));
float d3 = dot(tp - c, normalize(float2(a.y - c.y, c.x - a.x)));
float triangleDist = max(max(d1, d2), d3);
float edge = 1.0 - smoothstep(0.0, lineWidth, abs(triangleDist));
result = max(result, edge * (1.0 - float(i) * 0.12));
}
// Central bindu (point)
float bindu = 1.0 - smoothstep(0.03, 0.04, length(p));
result = max(result, bindu);
return result;
}
// Hexagonal grid pattern
float hexagonalPattern(float2 p, float scale, float time) {
p *= scale;
// Hexagonal grid transformation
float2 s = float2(1.0, 1.732);
float2 h = s * 0.5;
float2 a = fmod(p, s) - h;
float2 b = fmod(p + h, s) - h;
float2 gv = dot(a, a) < dot(b, b) ? a : b;
float hexDist = max(abs(gv.x), dot(abs(gv), normalize(float2(1.0, 1.732))));
float edge = 1.0 - smoothstep(0.4, 0.42, hexDist);
float fill = smoothstep(0.38, 0.4, hexDist);
// Animate individual hexagons
float2 cellId = floor(p / s);
float cellPhase = hash(cellId + floor(time * 0.5)) * 2.0 * 3.14159;
float pulse = 0.5 + 0.5 * sin(time * 3.0 + cellPhase);
return edge + fill * pulse * 0.3;
}
// === MAIN FRAGMENT SHADER ===
fragment float4 dmtGeometryFragment(
VertexOut in [[stage_in]],
constant ShaderUniforms& uniforms [[buffer(0)]],
constant float* fftData [[buffer(1)]],
constant float* melData [[buffer(2)]],
constant float* historyData [[buffer(3)]]
) {
float2 uv = in.uv;
float2 resolution = uniforms.resolution;
float time = uniforms.time;
float reactivity = uniforms.reactivity;
float subBass = uniforms.subBassEnergy;
float hnr = uniforms.hnrRatio;
float peak = uniforms.isPeak;
float peakIntensity = uniforms.peakIntensity;
// Aspect ratio correction
float aspectRatio = resolution.x / resolution.y;
float2 p = (uv - 0.5) * 2.0;
p.x *= aspectRatio;
// Scale pulsing with sub-bass
float scale = 2.0 + subBass * 0.5 * (0.5 + reactivity * 0.5);
p *= scale;
// Rotation
float rotation = time * 0.1;
p = rotate(p, rotation);
// Determine which geometry to show
// Changes on peaks or every few seconds
float cycleTime = 8.0; // Seconds per geometry
float cyclePhase = fmod(time, cycleTime * 4.0) / cycleTime;
int geometryIndex = int(cyclePhase);
// Force change on strong peaks
if (peak > 0.5 && peakIntensity > 0.7) {
geometryIndex = int(fmod(float(geometryIndex) + 1.0, 4.0));
}
// Calculate all geometries (for blending)
float flower = flowerOfLife(p, 1.0, time);
float metatron = metatronsCube(p, 1.5, time);
float yantra = sriYantra(p, 1.2, time);
float hexGrid = hexagonalPattern(p, 3.0, time);
// Select primary and secondary for blending
float primary = 0.0;
float secondary = 0.0;
float blendPhase = fract(cyclePhase);
switch (geometryIndex) {
case 0:
primary = flower;
secondary = metatron;
break;
case 1:
primary = metatron;
secondary = yantra;
break;
case 2:
primary = yantra;
secondary = hexGrid;
break;
default:
primary = hexGrid;
secondary = flower;
break;
}
// Smooth transition
float transitionWindow = 0.2; // 20% of cycle for transition
float blend = smoothstep(1.0 - transitionWindow, 1.0, blendPhase);
float geometry = mix(primary, secondary, blend);
// Complexity based on HNR (more harmonic = more detail)
geometry *= 0.7 + hnr * 0.3;
// Color based on geometry and audio
float colorPhase = time * 0.1 + geometry * 0.5;
float3 geometryColor = psytrancePalette(colorPhase, time);
// Glow intensity from peak
float glowIntensity = 0.5 + peakIntensity * 0.5;
float3 glowColor = mix(neonMagenta, neonCyan, 0.5 + 0.5 * sin(time));
// Compose final color
float3 finalColor = geometryColor * geometry;
// Add glow
finalColor = addGlow(finalColor, geometry * glowIntensity, glowColor);
// Background - subtle pulsing gradient
float dist = length(uv - 0.5);
float3 bgColor = mix(deepPurple, uvViolet * 0.3, dist);
bgColor *= 0.8 + 0.2 * subBass;
finalColor = mix(bgColor, finalColor, clamp(geometry * 1.5, 0.0, 1.0));
// Peak flash
if (peak > 0.5) {
finalColor += float3(1.0) * peakIntensity * 0.2;
}
// Outer glow
float outerGlow = exp(-dist * 3.0);
finalColor += neonMagenta * outerGlow * 0.1 * subBass;
return float4(finalColor, 1.0);
}
@@ -0,0 +1,117 @@
//
// FFTClassicShader.metal
// PsytranceVisualizer
//
// Classic FFT bar visualization with glow effects
//
#include <metal_stdlib>
using namespace metal;
// Include common definitions
#include "Common.metal"
fragment float4 fftClassicFragment(
VertexOut in [[stage_in]],
constant ShaderUniforms& uniforms [[buffer(0)]],
constant float* fftData [[buffer(1)]]
) {
float2 uv = in.uv;
float2 resolution = uniforms.resolution;
float time = uniforms.time;
float reactivity = uniforms.reactivity;
// Number of bars to display
const int numBars = 64;
const float barWidth = 1.0 / float(numBars);
const float barGap = barWidth * 0.2;
const float actualBarWidth = barWidth - barGap;
// Determine which bar this pixel belongs to
int barIndex = int(uv.x * float(numBars));
barIndex = clamp(barIndex, 0, numBars - 1);
// Get FFT magnitude for this bar (with some averaging for smoothness)
float magnitude = fftData[barIndex];
// Apply reactivity scaling
magnitude = magnitude * (0.5 + reactivity * 1.5);
magnitude = clamp(magnitude, 0.0, 1.0);
// Calculate bar position within its cell
float barCellX = fract(uv.x * float(numBars));
float barCenterX = 0.5;
// Distance from bar center (for width calculation)
float distFromCenter = abs(barCellX - barCenterX);
float halfWidth = actualBarWidth * 0.5 / barWidth;
// Check if we're inside the bar horizontally
bool insideBarX = distFromCenter < halfWidth;
// Bar height from bottom
float barHeight = magnitude;
// Add some bounce on peaks
if (uniforms.isPeak > 0.5) {
barHeight += uniforms.peakIntensity * 0.1 * sin(time * 20.0 + float(barIndex) * 0.3);
}
// Check if we're inside the bar vertically (from bottom)
float yFromBottom = 1.0 - uv.y;
bool insideBarY = yFromBottom < barHeight;
// Color based on frequency and magnitude
float colorPhase = float(barIndex) / float(numBars) + time * 0.05;
float3 barColor = psytrancePalette(colorPhase, time);
// Intensity gradient from bottom to top
float intensityGradient = yFromBottom / max(barHeight, 0.01);
intensityGradient = clamp(intensityGradient, 0.0, 1.0);
// Make top of bars brighter
barColor = mix(barColor * 0.6, barColor * 1.5, intensityGradient);
// Calculate glow
float glowRadius = 0.05 * (1.0 + magnitude);
float distToBar = 0.0;
if (!insideBarX) {
distToBar = (distFromCenter - halfWidth) * barWidth;
}
if (!insideBarY && yFromBottom >= barHeight) {
float vertDist = yFromBottom - barHeight;
distToBar = max(distToBar, vertDist);
}
float glow = exp(-distToBar * distToBar / (glowRadius * glowRadius * 2.0));
glow *= magnitude;
// Final color
float3 finalColor = float3(0.0);
if (insideBarX && insideBarY) {
// Inside the bar
finalColor = barColor;
// Add peak cap (bright line at top)
float capThickness = 0.01;
if (abs(yFromBottom - barHeight) < capThickness) {
finalColor = float3(1.0); // White cap
}
} else {
// Add glow outside bars
finalColor = barColor * glow * 0.5;
}
// Add subtle background pulse with sub-bass
float bgPulse = uniforms.subBassEnergy * 0.05;
finalColor += deepPurple * bgPulse;
// Add overall glow at peaks
if (uniforms.isPeak > 0.5) {
finalColor += neonMagenta * uniforms.peakIntensity * 0.1;
}
return float4(finalColor, 1.0);
}
@@ -0,0 +1,142 @@
//
// HNRShader.metal
// PsytranceVisualizer
//
// Harmonic-to-Noise ratio visualization with geometric shapes vs chaos
//
#include <metal_stdlib>
using namespace metal;
#include "Common.metal"
fragment float4 hnrFragment(
VertexOut in [[stage_in]],
constant ShaderUniforms& uniforms [[buffer(0)]],
constant float* fftData [[buffer(1)]],
constant float* melData [[buffer(2)]],
constant float* historyData [[buffer(3)]]
) {
float2 uv = in.uv;
float2 resolution = uniforms.resolution;
float time = uniforms.time;
float reactivity = uniforms.reactivity;
float hnr = uniforms.hnrRatio;
float subBass = uniforms.subBassEnergy;
// Center coordinates
float2 center = float2(0.5, 0.5);
float aspectRatio = resolution.x / resolution.y;
float2 p = uv - center;
p.x *= aspectRatio;
float dist = length(p);
float angle = atan2(p.y, p.x);
// === HARMONIC SIDE (High HNR = Clear geometric shapes) ===
// Rotating hexagon
float2 rotP = rotate(p, time * 0.5);
float hexDist = sdHexagon(rotP, 0.2 + subBass * 0.1);
float hexEdge = 1.0 - smoothstep(0.0, 0.02, abs(hexDist));
// Inner rotating triangle (star)
float2 rotP2 = rotate(p, -time * 0.3);
float starDist = sdStar(rotP2, 0.12 + subBass * 0.05, 3, 2.5);
float starEdge = 1.0 - smoothstep(0.0, 0.015, abs(starDist));
// Concentric circles
float circles = 0.0;
for (int i = 0; i < 4; i++) {
float radius = 0.1 + float(i) * 0.08 + sin(time + float(i)) * 0.02;
float circleDist = abs(dist - radius);
float circle = 1.0 - smoothstep(0.0, 0.008, circleDist);
circles += circle;
}
// Combine harmonic shapes
float harmonicShapes = hexEdge + starEdge * 0.8 + circles * 0.5;
harmonicShapes = clamp(harmonicShapes, 0.0, 1.0);
// Harmonic color - clean neon
float3 harmonicColor = mix(neonCyan, neonMagenta, 0.5 + 0.5 * sin(angle * 2.0 + time));
// === NOISE SIDE (Low HNR = Chaotic particles) ===
// Noise-based particles
float noiseField = 0.0;
for (int i = 0; i < 5; i++) {
float2 noiseP = p * (3.0 + float(i) * 2.0);
noiseP += time * float(i + 1) * 0.1;
float n = noise(noiseP);
n = pow(n, 2.0);
noiseField += n * (1.0 / float(i + 1));
}
noiseField = clamp(noiseField, 0.0, 1.0);
// Turbulent swirls
float2 turbP = p * 4.0;
float turbulence = fbm(turbP + time * 0.5, 4);
// Chaotic speckles
float speckles = 0.0;
for (int i = 0; i < 30; i++) {
float2 specklePos = float2(
hash(float2(float(i) * 0.1, time * 0.01)) - 0.5,
hash(float2(float(i) * 0.2, time * 0.01 + 0.5)) - 0.5
);
specklePos *= 0.8;
specklePos.x *= aspectRatio;
float speckleDist = length(p - specklePos);
float speckle = exp(-speckleDist * speckleDist * 500.0);
speckle *= hash(float2(float(i), floor(time * 2.0)));
speckles += speckle;
}
float noiseVisual = noiseField * 0.4 + turbulence * 0.3 + speckles * 0.3;
noiseVisual = clamp(noiseVisual, 0.0, 1.0);
// Noise color - harsh, flickering
float3 noiseColor = mix(hotPink, uvViolet, turbulence);
noiseColor *= 0.8 + 0.2 * sin(time * 20.0 + noise(p * 10.0) * 10.0);
// === BLEND based on HNR ===
// HNR determines the mix: 1.0 = pure harmonic, 0.0 = pure noise
float harmonicAmount = hnr;
float noiseAmount = 1.0 - hnr;
// Apply reactivity to make transition more dramatic
harmonicAmount = pow(harmonicAmount, 1.0 / (1.0 + reactivity));
float3 harmonicContrib = harmonicColor * harmonicShapes * harmonicAmount;
float3 noiseContrib = noiseColor * noiseVisual * noiseAmount;
float3 finalColor = harmonicContrib + noiseContrib;
// Add center indicator showing current HNR
float indicator = smoothstep(0.25, 0.24, dist) - smoothstep(0.24, 0.23, dist);
float indicatorFill = smoothstep(0.23, 0.22, dist);
// Split indicator by HNR
float harmonicSide = step(0.0, p.x);
float noiseSide = 1.0 - harmonicSide;
finalColor += neonCyan * indicator * 0.3;
finalColor += neonCyan * indicatorFill * harmonicSide * hnr * 0.2;
finalColor += hotPink * indicatorFill * noiseSide * (1.0 - hnr) * 0.2;
// Background glow
float bgGlow = exp(-dist * dist * 4.0);
float3 bgColor = mix(deepPurple, uvViolet * 0.3, dist);
finalColor += bgColor * (1.0 - clamp(harmonicShapes + noiseVisual, 0.0, 1.0));
// Peak flash
if (uniforms.isPeak > 0.5) {
finalColor += float3(1.0) * uniforms.peakIntensity * 0.15 * exp(-dist * 3.0);
}
return float4(finalColor, 1.0);
}
@@ -0,0 +1,121 @@
//
// MandelbrotShader.metal
// PsytranceVisualizer
//
// Audio-reactive Mandelbrot fractal with zoom and color cycling
//
#include <metal_stdlib>
using namespace metal;
#include "Common.metal"
fragment float4 mandelbrotFragment(
VertexOut in [[stage_in]],
constant ShaderUniforms& uniforms [[buffer(0)]],
constant float* fftData [[buffer(1)]],
constant float* melData [[buffer(2)]],
constant float* historyData [[buffer(3)]]
) {
float2 uv = in.uv;
float2 resolution = uniforms.resolution;
float time = uniforms.time;
float reactivity = uniforms.reactivity;
float subBass = uniforms.subBassEnergy;
float pump = uniforms.sidechainPump;
float centroid = uniforms.spectralCentroid;
// Aspect ratio correction
float aspectRatio = resolution.x / resolution.y;
// Map UV to complex plane
float2 c = (uv - 0.5) * 2.0;
c.x *= aspectRatio;
// Audio-reactive zoom level
// Base zoom increases over time, modulated by sub-bass
float baseZoom = 1.0 + time * 0.02;
float audioZoom = subBass * 0.5 * (0.5 + reactivity * 0.5);
float zoom = pow(2.0, baseZoom + audioZoom);
// Zoom center - drifts based on sidechain
float2 zoomCenter = float2(-0.7, 0.0);
zoomCenter.x += sin(time * 0.1) * 0.3 + pump * 0.1 * sin(time);
zoomCenter.y += cos(time * 0.13) * 0.2 + pump * 0.1 * cos(time);
// Apply zoom
c = c / zoom + zoomCenter;
// Mandelbrot iteration
float2 z = float2(0.0);
int maxIterations = int(50.0 + reactivity * 100.0);
int iterations = 0;
float smoothIter = 0.0;
for (int i = 0; i < 150; i++) {
if (i >= maxIterations) break;
// z = z^2 + c
float2 zNew = float2(
z.x * z.x - z.y * z.y + c.x,
2.0 * z.x * z.y + c.y
);
z = zNew;
float mag2 = dot(z, z);
if (mag2 > 256.0) {
// Smooth iteration count
smoothIter = float(i) - log2(log2(mag2)) + 4.0;
break;
}
iterations = i;
}
// Normalize iteration count
float normalizedIter = smoothIter / float(maxIterations);
// Color based on iterations
float3 color;
if (iterations >= maxIterations - 1) {
// Inside the set - deep color
color = deepPurple * (0.5 + 0.5 * subBass);
} else {
// Outside - color cycling based on iterations and audio
float colorPhase = normalizedIter + time * 0.1 + centroid;
// Use psytrance palette with color rotation
color = psytrancePalette(colorPhase, time);
// Modulate brightness by iteration depth
float brightness = 0.5 + 0.5 * sin(smoothIter * 0.3);
color *= brightness;
// Add glow at boundary
float edgeFactor = 1.0 - normalizedIter;
edgeFactor = pow(edgeFactor, 3.0);
color = addGlow(color, edgeFactor * 0.5, neonCyan);
}
// Sub-bass pulse effect
color *= 0.8 + 0.2 * subBass;
// Sidechain breathing
float breathe = 1.0 + pump * 0.1;
color *= breathe;
// Peak flash in bright areas
if (uniforms.isPeak > 0.5 && iterations < maxIterations - 1) {
color += neonMagenta * uniforms.peakIntensity * 0.2 * normalizedIter;
}
// Subtle vignette
float2 vignetteuv = uv - 0.5;
float vignette = 1.0 - dot(vignetteuv, vignetteuv) * 0.5;
color *= vignette;
return float4(color, 1.0);
}
@@ -0,0 +1,95 @@
//
// MelSpectrogramShader.metal
// PsytranceVisualizer
//
// Mel spectrogram with scrolling waterfall display
//
#include <metal_stdlib>
using namespace metal;
#include "Common.metal"
fragment float4 melSpectrogramFragment(
VertexOut in [[stage_in]],
constant ShaderUniforms& uniforms [[buffer(0)]],
constant float* fftData [[buffer(1)]],
constant float* melData [[buffer(2)]],
constant float* historyData [[buffer(3)]]
) {
float2 uv = in.uv;
float time = uniforms.time;
float reactivity = uniforms.reactivity;
// Configuration
const int numBands = 64;
const int historyLength = 128;
// Map UV to mel band and history position
int bandIndex = int(uv.x * float(numBands));
bandIndex = clamp(bandIndex, 0, numBands - 1);
// Scrolling effect - newer data at bottom
float scrollOffset = fract(time * 0.5); // Scroll speed
float yPos = fract(uv.y + scrollOffset);
// Get mel magnitude
float magnitude = melData[bandIndex];
magnitude = magnitude * (0.5 + reactivity * 1.5);
magnitude = clamp(magnitude, 0.0, 1.0);
// Create waterfall effect using history
int historyIndex = int(yPos * float(historyLength));
historyIndex = clamp(historyIndex, 0, historyLength - 1);
// Combine current and historical data for waterfall
float historicalValue = historyData[historyIndex];
// Blend between current magnitude and position-based intensity
float intensity = magnitude;
// Add some variance based on band position
float bandPhase = float(bandIndex) / float(numBands);
intensity *= 0.8 + 0.2 * sin(bandPhase * 6.28318 + time);
// Apply fade for older data (top of screen)
float ageFade = 1.0 - uv.y * 0.3;
intensity *= ageFade;
// Generate color using heatmap
float3 color = heatmap(intensity);
// Add frequency-dependent hue shift
float hueShift = bandPhase * 0.3;
color = psytrancePalette(intensity + hueShift, time);
// Modulate by actual intensity
color *= 0.3 + intensity * 0.7;
// Add grid lines for visual reference
float gridX = abs(fract(uv.x * float(numBands)) - 0.5) * 2.0;
float gridY = abs(fract(uv.y * 16.0) - 0.5) * 2.0;
float gridLine = smoothstep(0.95, 1.0, gridX) + smoothstep(0.95, 1.0, gridY);
gridLine *= 0.1;
color += float3(gridLine) * uvViolet;
// Add glow on high energy
if (intensity > 0.7) {
float glow = (intensity - 0.7) / 0.3;
color = addGlow(color, glow * 0.5, neonCyan);
}
// Peak flash
if (uniforms.isPeak > 0.5) {
color += neonMagenta * uniforms.peakIntensity * 0.15;
}
// Sub-bass emphasis on lower bands
if (bandIndex < 8) {
color += uvViolet * uniforms.subBassEnergy * 0.3;
}
return float4(color, 1.0);
}
@@ -0,0 +1,130 @@
//
// SidechainPumpShader.metal
// PsytranceVisualizer
//
// Visualizes sidechain pumping with breathing zoom effect
//
#include <metal_stdlib>
using namespace metal;
#include "Common.metal"
fragment float4 sidechainPumpFragment(
VertexOut in [[stage_in]],
constant ShaderUniforms& uniforms [[buffer(0)]],
constant float* fftData [[buffer(1)]],
constant float* melData [[buffer(2)]],
constant float* historyData [[buffer(3)]]
) {
float2 uv = in.uv;
float2 resolution = uniforms.resolution;
float time = uniforms.time;
float reactivity = uniforms.reactivity;
float pump = uniforms.sidechainPump;
float envelope = uniforms.sidechainEnvelope;
float subBass = uniforms.subBassEnergy;
// Center and aspect ratio correction
float2 center = float2(0.5, 0.5);
float aspectRatio = resolution.x / resolution.y;
float2 p = uv - center;
p.x *= aspectRatio;
// Apply breathing zoom effect
float zoomAmount = 1.0 + pump * 0.3 * (0.5 + reactivity * 0.5);
p /= zoomAmount;
// Radial distortion synchronized with pump
float dist = length(p);
float angle = atan2(p.y, p.x);
// Pump-synced radial waves
float radialWave = sin(dist * 15.0 - time * 3.0 + envelope * 10.0);
radialWave *= pump * 0.3;
// Apply distortion
float2 distortedP = p;
distortedP *= 1.0 + radialWave * 0.1;
// Create concentric pulse rings
float rings = 0.0;
const int numRings = 5;
for (int i = 0; i < numRings; i++) {
float ringPhase = fract(time * 0.5 + float(i) * 0.2 - envelope * 0.5);
float ringRadius = ringPhase * 0.6;
float ringWidth = 0.02 + pump * 0.03;
float ringDist = abs(dist - ringRadius);
float ring = exp(-ringDist * ringDist / (ringWidth * ringWidth));
ring *= 1.0 - ringPhase; // Fade out as it expands
ring *= pump;
rings += ring;
}
// Breathing glow in center
float breathIntensity = 0.5 + 0.5 * sin(time * 4.0 + envelope * 6.28318);
breathIntensity *= pump;
float centerGlow = exp(-dist * dist * 8.0);
centerGlow *= breathIntensity;
// Color based on pump phase
float3 pumpColor = mix(uvViolet, neonMagenta, envelope);
float3 ringColor = mix(neonCyan, hotPink, pump);
// Background pattern - angular sectors that pulse
float sectors = 8.0;
float sectorAngle = fract(angle / (2.0 * 3.14159) * sectors);
float sectorPulse = smoothstep(0.4, 0.5, sectorAngle) - smoothstep(0.5, 0.6, sectorAngle);
sectorPulse *= pump * 0.3;
sectorPulse *= exp(-dist * 3.0);
// Spiral pattern
float spiral = fract(angle / (2.0 * 3.14159) * 3.0 + dist * 5.0 - time * 0.5);
spiral = smoothstep(0.4, 0.5, spiral) - smoothstep(0.5, 0.6, spiral);
spiral *= pump * 0.2;
spiral *= exp(-dist * 2.0);
// Compose final color
float3 finalColor = float3(0.0);
// Base gradient
float3 bgGradient = mix(deepPurple, uvViolet * 0.3, dist);
finalColor += bgGradient;
// Add rings
finalColor += ringColor * rings;
// Add center glow
finalColor += pumpColor * centerGlow;
// Add sector pulse
finalColor += neonGreen * sectorPulse;
// Add spiral
finalColor += electricBlue * spiral;
// Screen flash on strong pump
if (pump > 0.7) {
float flash = (pump - 0.7) / 0.3;
flash *= 0.2;
finalColor += neonMagenta * flash;
}
// Peak highlight
if (uniforms.isPeak > 0.5) {
float peakFlash = uniforms.peakIntensity * 0.2;
finalColor += float3(1.0) * peakFlash * exp(-dist * 5.0);
}
// Vignette
float vignette = 1.0 - smoothstep(0.4, 0.8, dist);
finalColor *= 0.7 + vignette * 0.3;
return float4(finalColor, 1.0);
}
@@ -0,0 +1,116 @@
//
// SubBassShader.metal
// PsytranceVisualizer
//
// Pulsating rings visualizing sub-bass energy below 100Hz
//
#include <metal_stdlib>
using namespace metal;
#include "Common.metal"
fragment float4 subBassFragment(
VertexOut in [[stage_in]],
constant ShaderUniforms& uniforms [[buffer(0)]],
constant float* fftData [[buffer(1)]],
constant float* melData [[buffer(2)]],
constant float* historyData [[buffer(3)]]
) {
float2 uv = in.uv;
float2 resolution = uniforms.resolution;
float time = uniforms.time;
float reactivity = uniforms.reactivity;
float subBass = uniforms.subBassEnergy;
// Center coordinates
float2 center = float2(0.5, 0.5);
float aspectRatio = resolution.x / resolution.y;
// Correct for aspect ratio
float2 p = uv - center;
p.x *= aspectRatio;
float dist = length(p);
float angle = atan2(p.y, p.x);
// Main pulsating circle
float baseRadius = 0.15;
float pulseAmount = subBass * (0.5 + reactivity * 0.5);
float mainRadius = baseRadius + pulseAmount * 0.2;
// Add wobble based on angle
float wobble = sin(angle * 4.0 + time * 2.0) * 0.02 * subBass;
mainRadius += wobble;
// Core circle
float coreDist = abs(dist - mainRadius);
float coreGlow = exp(-coreDist * coreDist * 200.0);
// Inner fill with gradient
float innerFill = smoothstep(mainRadius, mainRadius * 0.3, dist);
innerFill *= 0.5 + 0.5 * subBass;
// Expanding rings
const int numRings = 6;
float ringIntensity = 0.0;
for (int i = 0; i < numRings; i++) {
// Each ring expands outward over time
float ringPhase = fract(time * 0.3 - float(i) * 0.15);
float ringRadius = mainRadius + ringPhase * 0.5;
// Get historical sub-bass value for this ring
int histIndex = clamp(int(ringPhase * 64.0), 0, 63);
float histValue = historyData[histIndex];
// Ring thickness based on historical energy
float thickness = 0.005 + histValue * 0.01;
float ringDist = abs(dist - ringRadius);
// Ring visibility
float ring = exp(-ringDist * ringDist / (thickness * thickness));
ring *= (1.0 - ringPhase); // Fade as it expands
ring *= histValue; // Intensity based on history
ringIntensity += ring;
}
// Color composition
float3 coreColor = mix(uvViolet, neonMagenta, subBass);
float3 ringColor = mix(neonMagenta, hotPink, 0.5 + 0.5 * sin(time));
float3 finalColor = float3(0.0);
// Add core
finalColor += coreColor * (innerFill + coreGlow * 2.0);
// Add rings
finalColor += ringColor * ringIntensity * 0.8;
// Add central glow
float centerGlow = exp(-dist * dist * 10.0) * subBass;
finalColor += uvViolet * centerGlow * 0.5;
// Add angular rays on peaks
if (uniforms.isPeak > 0.5) {
float rays = abs(sin(angle * 8.0 + time * 5.0));
rays = pow(rays, 4.0) * exp(-dist * 2.0);
rays *= uniforms.peakIntensity;
finalColor += neonCyan * rays * 0.5;
}
// Outer vignette
float vignette = 1.0 - smoothstep(0.3, 0.8, dist);
finalColor *= vignette;
// Background pulse
float bgPulse = subBass * 0.1;
finalColor += deepPurple * bgPulse;
// Add noise texture for organic feel
float noiseVal = noise(p * 20.0 + time);
finalColor += uvViolet * noiseVal * 0.02 * subBass;
return float4(finalColor, 1.0);
}
@@ -0,0 +1,136 @@
//
// TunnelWarpShader.metal
// PsytranceVisualizer
//
// Infinite tunnel effect with warp distortion
//
#include <metal_stdlib>
using namespace metal;
#include "Common.metal"
fragment float4 tunnelWarpFragment(
VertexOut in [[stage_in]],
constant ShaderUniforms& uniforms [[buffer(0)]],
constant float* fftData [[buffer(1)]],
constant float* melData [[buffer(2)]],
constant float* historyData [[buffer(3)]]
) {
float2 uv = in.uv;
float2 resolution = uniforms.resolution;
float time = uniforms.time;
float reactivity = uniforms.reactivity;
float subBass = uniforms.subBassEnergy;
float pump = uniforms.sidechainPump;
float hnr = uniforms.hnrRatio;
// Center and aspect correction
float aspectRatio = resolution.x / resolution.y;
float2 p = (uv - 0.5) * 2.0;
p.x *= aspectRatio;
// Convert to polar coordinates for tunnel
float dist = length(p);
float angle = atan2(p.y, p.x);
// Avoid division by zero at center
dist = max(dist, 0.001);
// Tunnel depth (inverse of distance)
float depth = 1.0 / dist;
// Speed controlled by sub-bass
float baseSpeed = 2.0;
float audioSpeed = subBass * 3.0 * (0.5 + reactivity * 0.5);
float speed = baseSpeed + audioSpeed;
// Warp distortion from sidechain pump
float warpAmount = pump * 0.5;
depth += sin(angle * 4.0 + time * 2.0) * warpAmount * 0.5;
angle += sin(depth * 2.0 + time) * warpAmount * 0.3;
// Create tunnel coordinates
float2 tunnelUV = float2(
angle / (2.0 * 3.14159) + 0.5, // Angular coordinate [0, 1]
depth + time * speed // Depth with movement
);
// === TUNNEL WALL PATTERNS ===
// Hexagonal grid pattern
float2 hexUV = tunnelUV * float2(8.0, 2.0);
float2 hexCell = floor(hexUV);
float2 hexFrac = fract(hexUV);
// Offset every other row
if (fmod(hexCell.y, 2.0) > 0.5) {
hexFrac.x = fract(hexFrac.x + 0.5);
}
float hexDist = length(hexFrac - 0.5);
float hexPattern = smoothstep(0.4, 0.35, hexDist);
// Add concentric rings
float rings = sin(tunnelUV.y * 20.0) * 0.5 + 0.5;
rings = smoothstep(0.3, 0.7, rings);
// Angular segments
float segments = 8.0;
float angularLines = abs(sin(angle * segments));
angularLines = smoothstep(0.95, 1.0, angularLines);
// Combine patterns
float pattern = hexPattern * 0.5 + rings * 0.3 + angularLines * 0.2;
// === COLORING ===
// Base color cycles with depth and time
float colorPhase = tunnelUV.y * 0.1 + time * 0.2;
float3 tunnelColor = psytrancePalette(colorPhase, time);
// Depth fog (darker towards center/infinity)
float fog = exp(-dist * 2.0);
tunnelColor *= fog;
// Pattern overlay
float3 patternColor = mix(uvViolet, neonCyan, rings);
tunnelColor = mix(tunnelColor, patternColor, pattern * 0.5);
// Edge glow (bright at tunnel edges)
float edgeGlow = exp(-dist * 5.0);
tunnelColor = addGlow(tunnelColor, (1.0 - edgeGlow) * 0.3, neonMagenta);
// Center light (looking into the tunnel)
float centerLight = exp(-dist * dist * 50.0);
tunnelColor += float3(1.0) * centerLight * 0.5;
// HNR affects pattern complexity
float patternIntensity = hnr;
tunnelColor *= 0.7 + patternIntensity * 0.3;
// Add noise for texture
float noiseVal = noise(tunnelUV * 10.0 + time);
tunnelColor += uvViolet * noiseVal * 0.1;
// Pump flash
if (pump > 0.5) {
float pumpFlash = (pump - 0.5) * 2.0;
tunnelColor += neonMagenta * pumpFlash * 0.2;
}
// Peak flash
if (uniforms.isPeak > 0.5) {
float peakFlash = uniforms.peakIntensity;
tunnelColor += float3(1.0) * peakFlash * 0.15 * (1.0 - edgeGlow);
}
// Speed lines effect
float speedLines = fract(tunnelUV.y * 50.0 - time * speed * 2.0);
speedLines = smoothstep(0.95, 1.0, speedLines);
speedLines *= subBass * 0.5;
tunnelColor += neonCyan * speedLines;
return float4(tunnelColor, 1.0);
}
@@ -0,0 +1,20 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "1.000",
"green" : "0.000",
"red" : "1.000"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,58 @@
{
"images" : [
{
"idiom" : "mac",
"scale" : "1x",
"size" : "16x16"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "16x16"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "32x32"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "32x32"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "128x128"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "128x128"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "256x256"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "256x256"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "512x512"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "512x512"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
+38
View File
@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIconFile</key>
<string></string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.music</string>
<key>LSMinimumSystemVersion</key>
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
<key>NSHumanReadableCopyright</key>
<string>Copyright © 2024. All rights reserved.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Psytrance Visualizer needs access to your audio input to visualize music in real-time. You can use a virtual audio device like BlackHole to route system audio.</string>
<key>NSPrincipalClass</key>
<string>NSApplication</string>
<key>NSHighResolutionCapable</key>
<true/>
<key>NSSupportsAutomaticGraphicsSwitching</key>
<true/>
</dict>
</plist>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.device.audio-input</key>
<true/>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
</dict>
</plist>
+315
View File
@@ -0,0 +1,315 @@
//
// ControlPanel.swift
// PsytranceVisualizer
//
// Auto-hiding control panel with audio and visualization settings
//
import AppKit
import Combine
/// Delegate protocol for control panel actions
protocol ControlPanelDelegate: AnyObject {
func controlPanel(_ panel: ControlPanel, didSelectDevice uid: String)
func controlPanel(_ panel: ControlPanel, didSelectBufferSize size: Int)
func controlPanel(_ panel: ControlPanel, didSelectMode mode: VisualizationMode)
func controlPanel(_ panel: ControlPanel, didChangeReactivity value: Float)
func controlPanelDidRequestFullscreen(_ panel: ControlPanel)
}
/// Auto-hiding control panel overlay
final class ControlPanel: NSView {
// MARK: - Properties
weak var delegate: ControlPanelDelegate?
private var isVisible = true
private var hideTimer: Timer?
private let hideDelay: TimeInterval = 3.0
private var audioDevices: [AudioDevice] = []
private var selectedMode: VisualizationMode = .fftClassic
// MARK: - UI Elements
private let containerView = NSVisualEffectView()
private let devicePopup = NSPopUpButton()
private let bufferSizePopup = NSPopUpButton()
private let modeSegment = NSSegmentedControl()
private let reactivitySlider = NSSlider()
private let reactivityLabel = NSTextField(labelWithString: "Reactivity")
private let fullscreenButton = NSButton()
// MARK: - Layout Constants
private let panelHeight: CGFloat = 60
private let padding: CGFloat = 12
private let elementHeight: CGFloat = 24
// MARK: - Initialization
override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
setupUI()
setupConstraints()
startHideTimer()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: - Setup
private func setupUI() {
// Container with vibrancy effect
containerView.material = .hudWindow
containerView.blendingMode = .behindWindow
containerView.state = .active
containerView.wantsLayer = true
containerView.layer?.cornerRadius = 12
containerView.layer?.masksToBounds = true
addSubview(containerView)
// Device popup
devicePopup.target = self
devicePopup.action = #selector(deviceChanged)
devicePopup.controlSize = .small
devicePopup.font = .systemFont(ofSize: 11)
containerView.addSubview(devicePopup)
// Buffer size popup
bufferSizePopup.target = self
bufferSizePopup.action = #selector(bufferSizeChanged)
bufferSizePopup.controlSize = .small
bufferSizePopup.font = .systemFont(ofSize: 11)
bufferSizePopup.addItems(withTitles: ["512", "1024"])
bufferSizePopup.selectItem(withTitle: "1024")
containerView.addSubview(bufferSizePopup)
// Mode segment control
modeSegment.segmentCount = 8
for mode in VisualizationMode.allCases {
modeSegment.setLabel(mode.shortcut, forSegment: mode.rawValue - 1)
modeSegment.setToolTip(mode.displayName, forSegment: mode.rawValue - 1)
}
modeSegment.selectedSegment = 0
modeSegment.target = self
modeSegment.action = #selector(modeChanged)
modeSegment.controlSize = .small
modeSegment.segmentStyle = .capsule
containerView.addSubview(modeSegment)
// Reactivity label
reactivityLabel.font = .systemFont(ofSize: 10)
reactivityLabel.textColor = .secondaryLabelColor
containerView.addSubview(reactivityLabel)
// Reactivity slider
reactivitySlider.minValue = 0.0
reactivitySlider.maxValue = 1.0
reactivitySlider.doubleValue = 0.5
reactivitySlider.target = self
reactivitySlider.action = #selector(reactivityChanged)
reactivitySlider.controlSize = .small
containerView.addSubview(reactivitySlider)
// Fullscreen button
fullscreenButton.title = ""
fullscreenButton.bezelStyle = .accessoryBarAction
fullscreenButton.target = self
fullscreenButton.action = #selector(fullscreenClicked)
fullscreenButton.toolTip = "Toggle Fullscreen (F)"
containerView.addSubview(fullscreenButton)
// Set colors
applyPsytranceTheme()
}
private func applyPsytranceTheme() {
// Custom appearance for psytrance aesthetic
containerView.appearance = NSAppearance(named: .darkAqua)
}
private func setupConstraints() {
containerView.translatesAutoresizingMaskIntoConstraints = false
devicePopup.translatesAutoresizingMaskIntoConstraints = false
bufferSizePopup.translatesAutoresizingMaskIntoConstraints = false
modeSegment.translatesAutoresizingMaskIntoConstraints = false
reactivityLabel.translatesAutoresizingMaskIntoConstraints = false
reactivitySlider.translatesAutoresizingMaskIntoConstraints = false
fullscreenButton.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
// Container
containerView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: padding),
containerView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -padding),
containerView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -padding),
containerView.heightAnchor.constraint(equalToConstant: panelHeight),
// Device popup
devicePopup.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: padding),
devicePopup.centerYAnchor.constraint(equalTo: containerView.centerYAnchor),
devicePopup.widthAnchor.constraint(equalToConstant: 150),
// Buffer size popup
bufferSizePopup.leadingAnchor.constraint(equalTo: devicePopup.trailingAnchor, constant: 8),
bufferSizePopup.centerYAnchor.constraint(equalTo: containerView.centerYAnchor),
bufferSizePopup.widthAnchor.constraint(equalToConstant: 60),
// Mode segment
modeSegment.centerXAnchor.constraint(equalTo: containerView.centerXAnchor),
modeSegment.centerYAnchor.constraint(equalTo: containerView.centerYAnchor),
// Reactivity label
reactivityLabel.trailingAnchor.constraint(equalTo: reactivitySlider.leadingAnchor, constant: -4),
reactivityLabel.centerYAnchor.constraint(equalTo: containerView.centerYAnchor),
// Reactivity slider
reactivitySlider.trailingAnchor.constraint(equalTo: fullscreenButton.leadingAnchor, constant: -padding),
reactivitySlider.centerYAnchor.constraint(equalTo: containerView.centerYAnchor),
reactivitySlider.widthAnchor.constraint(equalToConstant: 80),
// Fullscreen button
fullscreenButton.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -padding),
fullscreenButton.centerYAnchor.constraint(equalTo: containerView.centerYAnchor),
])
}
// MARK: - Public Methods
/// Updates the list of available audio devices
func updateDevices(_ devices: [AudioDevice], selectedUID: String?) {
audioDevices = devices
devicePopup.removeAllItems()
for device in devices {
devicePopup.addItem(withTitle: device.name)
devicePopup.lastItem?.representedObject = device.uid
}
if let uid = selectedUID,
let index = devices.firstIndex(where: { $0.uid == uid }) {
devicePopup.selectItem(at: index)
}
}
/// Updates the selected buffer size
func updateBufferSize(_ size: Int) {
bufferSizePopup.selectItem(withTitle: "\(size)")
}
/// Updates the selected visualization mode
func updateMode(_ mode: VisualizationMode) {
selectedMode = mode
modeSegment.selectedSegment = mode.rawValue - 1
}
/// Updates the reactivity slider
func updateReactivity(_ value: Float) {
reactivitySlider.doubleValue = Double(value)
}
/// Shows the control panel
func show(animated: Bool = true) {
guard !isVisible else { return }
isVisible = true
if animated {
NSAnimationContext.runAnimationGroup { context in
context.duration = 0.3
self.animator().alphaValue = 1.0
}
} else {
alphaValue = 1.0
}
startHideTimer()
}
/// Hides the control panel
func hide(animated: Bool = true) {
guard isVisible else { return }
isVisible = false
hideTimer?.invalidate()
if animated {
NSAnimationContext.runAnimationGroup { context in
context.duration = 0.3
self.animator().alphaValue = 0.0
}
} else {
alphaValue = 0.0
}
}
/// Resets the hide timer (call on mouse movement)
func resetHideTimer() {
show()
startHideTimer()
}
// MARK: - Private Methods
private func startHideTimer() {
hideTimer?.invalidate()
hideTimer = Timer.scheduledTimer(withTimeInterval: hideDelay, repeats: false) { [weak self] _ in
self?.hide()
}
}
// MARK: - Actions
@objc private func deviceChanged() {
guard let uid = devicePopup.selectedItem?.representedObject as? String else { return }
delegate?.controlPanel(self, didSelectDevice: uid)
}
@objc private func bufferSizeChanged() {
guard let title = bufferSizePopup.selectedItem?.title,
let size = Int(title) else { return }
delegate?.controlPanel(self, didSelectBufferSize: size)
}
@objc private func modeChanged() {
let modeIndex = modeSegment.selectedSegment + 1
guard let mode = VisualizationMode(rawValue: modeIndex) else { return }
selectedMode = mode
delegate?.controlPanel(self, didSelectMode: mode)
}
@objc private func reactivityChanged() {
let value = Float(reactivitySlider.doubleValue)
delegate?.controlPanel(self, didChangeReactivity: value)
}
@objc private func fullscreenClicked() {
delegate?.controlPanelDidRequestFullscreen(self)
}
// MARK: - Mouse Tracking
override func updateTrackingAreas() {
super.updateTrackingAreas()
// Remove existing tracking areas
for area in trackingAreas {
removeTrackingArea(area)
}
// Add new tracking area
let options: NSTrackingArea.Options = [.mouseEnteredAndExited, .mouseMoved, .activeAlways]
let trackingArea = NSTrackingArea(rect: bounds, options: options, owner: self, userInfo: nil)
addTrackingArea(trackingArea)
}
override func mouseMoved(with event: NSEvent) {
super.mouseMoved(with: event)
resetHideTimer()
}
override func mouseEntered(with event: NSEvent) {
super.mouseEntered(with: event)
show()
}
}
+323
View File
@@ -0,0 +1,323 @@
//
// MainWindow.swift
// PsytranceVisualizer
//
// Main application window with keyboard handling
//
import AppKit
import Combine
/// Main window controller for the visualizer
final class MainWindowController: NSWindowController {
// MARK: - Properties
private var visualizerView: VisualizerView!
private var controlPanel: ControlPanel!
private var audioManager: AudioInputManager!
private var dspEngine: DSPEngine!
private var settingsManager: SettingsManager { .shared }
private var cancellables = Set<AnyCancellable>()
private var displayLink: CVDisplayLink?
// MARK: - Initialization
convenience init() {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 1280, height: 720),
styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
backing: .buffered,
defer: false
)
window.title = "Psytrance Visualizer"
window.minSize = NSSize(width: 800, height: 600)
window.titlebarAppearsTransparent = true
window.titleVisibility = .hidden
window.isMovableByWindowBackground = true
window.backgroundColor = .black
window.collectionBehavior = [.fullScreenPrimary]
// Restore window frame if saved
if let savedFrame = SettingsManager.shared.settings.windowFrame?.cgRect {
window.setFrame(savedFrame, display: false)
} else {
window.center()
}
self.init(window: window)
setupContent()
setupAudio()
setupKeyboardHandling()
restoreSettings()
}
// MARK: - Setup
private func setupContent() {
guard let contentView = window?.contentView else { return }
// Visualizer view (fills entire window)
visualizerView = VisualizerView()
visualizerView.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(visualizerView)
// Control panel (overlay at bottom)
controlPanel = ControlPanel()
controlPanel.translatesAutoresizingMaskIntoConstraints = false
controlPanel.delegate = self
contentView.addSubview(controlPanel)
NSLayoutConstraint.activate([
// Visualizer fills entire window
visualizerView.topAnchor.constraint(equalTo: contentView.topAnchor),
visualizerView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
visualizerView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
visualizerView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
// Control panel at bottom
controlPanel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
controlPanel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
controlPanel.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
controlPanel.heightAnchor.constraint(equalToConstant: 90),
])
// Mouse tracking for control panel
setupMouseTracking()
}
private func setupAudio() {
audioManager = AudioInputManager()
dspEngine = DSPEngine(bufferSize: settingsManager.settings.bufferSize)
// Audio buffer callback
audioManager.onAudioBuffer = { [weak self] buffer in
guard let self = self else { return }
let analysisData = self.dspEngine.process(buffer: buffer)
DispatchQueue.main.async {
self.visualizerView.updateAudioData(analysisData)
}
}
// Update control panel when devices change
audioManager.$availableDevices
.receive(on: DispatchQueue.main)
.sink { [weak self] devices in
self?.controlPanel.updateDevices(
devices,
selectedUID: self?.settingsManager.settings.selectedAudioDeviceUID
)
}
.store(in: &cancellables)
// Start audio
audioManager.start()
}
private func setupKeyboardHandling() {
// Monitor for key events
NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
if self?.handleKeyDown(event) == true {
return nil // Event handled
}
return event
}
}
private func setupMouseTracking() {
guard let contentView = window?.contentView else { return }
let options: NSTrackingArea.Options = [.mouseMoved, .activeAlways, .inVisibleRect]
let trackingArea = NSTrackingArea(
rect: contentView.bounds,
options: options,
owner: self,
userInfo: nil
)
contentView.addTrackingArea(trackingArea)
}
private func restoreSettings() {
let settings = settingsManager.settings
// Restore visualization mode
if let mode = VisualizationMode(rawValue: settings.lastVisualizationMode) {
visualizerView.setVisualizationMode(mode)
controlPanel.updateMode(mode)
}
// Restore reactivity
visualizerView.setReactivity(settings.reactivity)
dspEngine.setReactivity(settings.reactivity)
controlPanel.updateReactivity(settings.reactivity)
// Restore buffer size
dspEngine.setBufferSize(settings.bufferSize)
audioManager.setBufferSize(settings.bufferSize)
controlPanel.updateBufferSize(settings.bufferSize)
// Restore audio device
if let deviceUID = settings.selectedAudioDeviceUID {
audioManager.selectDevice(uid: deviceUID)
}
// Restore fullscreen state
if settings.isFullscreen {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
self?.window?.toggleFullScreen(nil)
}
}
}
// MARK: - Keyboard Handling
private func handleKeyDown(_ event: NSEvent) -> Bool {
// Check for visualization mode shortcuts (1-8)
if let mode = VisualizationMode.fromKeyCode(event.keyCode) {
setVisualizationMode(mode)
return true
}
// Other keyboard shortcuts
switch event.keyCode {
case 3: // F key
toggleFullscreen()
return true
case 53: // Escape
if window?.styleMask.contains(.fullScreen) == true {
window?.toggleFullScreen(nil)
}
return true
case 49: // Space
// Toggle pause (could be implemented)
return true
default:
break
}
// Cmd+F for fullscreen
if event.modifierFlags.contains(.command) && event.keyCode == 3 {
toggleFullscreen()
return true
}
return false
}
// MARK: - Mode Switching
private func setVisualizationMode(_ mode: VisualizationMode) {
visualizerView.setVisualizationMode(mode)
controlPanel.updateMode(mode)
settingsManager.setVisualizationMode(mode)
}
// MARK: - Fullscreen
private func toggleFullscreen() {
window?.toggleFullScreen(nil)
}
// MARK: - Mouse Events
override func mouseMoved(with event: NSEvent) {
controlPanel.resetHideTimer()
}
// MARK: - Window Events
override func windowDidLoad() {
super.windowDidLoad()
// Save window frame on move/resize
NotificationCenter.default.addObserver(
self,
selector: #selector(windowDidResize),
name: NSWindow.didResizeNotification,
object: window
)
NotificationCenter.default.addObserver(
self,
selector: #selector(windowDidMove),
name: NSWindow.didMoveNotification,
object: window
)
NotificationCenter.default.addObserver(
self,
selector: #selector(windowDidEnterFullScreen),
name: NSWindow.didEnterFullScreenNotification,
object: window
)
NotificationCenter.default.addObserver(
self,
selector: #selector(windowDidExitFullScreen),
name: NSWindow.didExitFullScreenNotification,
object: window
)
}
@objc private func windowDidResize(_ notification: Notification) {
if let frame = window?.frame {
settingsManager.setWindowFrame(frame)
}
}
@objc private func windowDidMove(_ notification: Notification) {
if let frame = window?.frame {
settingsManager.setWindowFrame(frame)
}
}
@objc private func windowDidEnterFullScreen(_ notification: Notification) {
settingsManager.setFullscreen(true)
controlPanel.hide()
}
@objc private func windowDidExitFullScreen(_ notification: Notification) {
settingsManager.setFullscreen(false)
controlPanel.show()
}
// MARK: - Cleanup
deinit {
audioManager.stop()
settingsManager.saveNow()
}
}
// MARK: - ControlPanelDelegate
extension MainWindowController: ControlPanelDelegate {
func controlPanel(_ panel: ControlPanel, didSelectDevice uid: String) {
audioManager.selectDevice(uid: uid)
settingsManager.setAudioDevice(uid: uid)
}
func controlPanel(_ panel: ControlPanel, didSelectBufferSize size: Int) {
audioManager.setBufferSize(size)
dspEngine.setBufferSize(size)
settingsManager.setBufferSize(size)
}
func controlPanel(_ panel: ControlPanel, didSelectMode mode: VisualizationMode) {
setVisualizationMode(mode)
}
func controlPanel(_ panel: ControlPanel, didChangeReactivity value: Float) {
visualizerView.setReactivity(value)
dspEngine.setReactivity(value)
settingsManager.setReactivity(value)
}
func controlPanelDidRequestFullscreen(_ panel: ControlPanel) {
toggleFullscreen()
}
}
+122
View File
@@ -0,0 +1,122 @@
//
// VisualizerView.swift
// PsytranceVisualizer
//
// MTKView subclass for rendering visualizations
//
import MetalKit
import Combine
/// MTKView subclass that displays audio-reactive visualizations
final class VisualizerView: MTKView {
// MARK: - Properties
private var renderer: MetalRenderer?
private var cancellables = Set<AnyCancellable>()
// MARK: - Initialization
init() {
// Get default Metal device
guard let device = MTLCreateSystemDefaultDevice() else {
fatalError("Metal is not supported on this device")
}
super.init(frame: .zero, device: device)
configure()
setupRenderer()
}
required init(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: - Configuration
private func configure() {
// Background color
clearColor = MTLClearColor(red: 0, green: 0, blue: 0, alpha: 1)
// Color format
colorPixelFormat = .bgra8Unorm
// Enable display link for smooth rendering
isPaused = false
enableSetNeedsDisplay = false
// Use display refresh rate
preferredFramesPerSecond = 120 // Will cap to display refresh
// Layer configuration
layer?.isOpaque = true
// Allow high DPI
layer?.contentsScale = NSScreen.main?.backingScaleFactor ?? 2.0
}
private func setupRenderer() {
guard let device = device else { return }
renderer = MetalRenderer(device: device)
delegate = renderer
// Initial size update
if let renderer = renderer {
let size = drawableSize
renderer.mtkView(self, drawableSizeWillChange: size)
}
}
// MARK: - Public Methods
/// Returns the Metal renderer
func getRenderer() -> MetalRenderer? {
return renderer
}
/// Updates audio data for visualization
func updateAudioData(_ data: AudioAnalysisData) {
renderer?.updateAudioData(data)
}
/// Sets the visualization mode
func setVisualizationMode(_ mode: VisualizationMode) {
renderer?.setVisualizationMode(mode)
}
/// Sets reactivity value
func setReactivity(_ value: Float) {
renderer?.setReactivity(value)
}
/// Gets current visualization mode
var currentMode: VisualizationMode {
renderer?.currentMode ?? .fftClassic
}
}
// MARK: - SwiftUI Bridge
#if canImport(SwiftUI)
import SwiftUI
/// SwiftUI wrapper for VisualizerView
struct VisualizerViewRepresentable: NSViewRepresentable {
@Binding var audioData: AudioAnalysisData
@Binding var mode: VisualizationMode
@Binding var reactivity: Float
func makeNSView(context: Context) -> VisualizerView {
let view = VisualizerView()
return view
}
func updateNSView(_ nsView: VisualizerView, context: Context) {
nsView.updateAudioData(audioData)
nsView.setVisualizationMode(mode)
nsView.setReactivity(reactivity)
}
}
#endif
@@ -0,0 +1,140 @@
//
// ColorPalette.swift
// PsytranceVisualizer
//
// Psytrance color palette for UI and shaders
//
import AppKit
import simd
/// Psytrance-inspired neon/UV color palette
struct PsytranceColors {
// MARK: - Primary Colors (NSColor for UI)
/// Neon Magenta - Primary accent color
static let neonMagenta = NSColor(red: 1.0, green: 0.0, blue: 1.0, alpha: 1.0)
/// Neon Cyan - Secondary accent color
static let neonCyan = NSColor(red: 0.0, green: 1.0, blue: 1.0, alpha: 1.0)
/// Neon Green - High energy accents
static let neonGreen = NSColor(red: 0.224, green: 1.0, blue: 0.078, alpha: 1.0)
/// UV Violet - Deep purple for backgrounds
static let uvViolet = NSColor(red: 0.482, green: 0.0, blue: 1.0, alpha: 1.0)
/// Deep Black - Background color
static let background = NSColor(red: 0.0, green: 0.0, blue: 0.0, alpha: 1.0)
/// Dark Purple - Alternative background
static let darkPurple = NSColor(red: 0.1, green: 0.0, blue: 0.15, alpha: 1.0)
/// Hot Pink - Peak indicators
static let hotPink = NSColor(red: 1.0, green: 0.2, blue: 0.6, alpha: 1.0)
/// Electric Blue - UI elements
static let electricBlue = NSColor(red: 0.0, green: 0.5, blue: 1.0, alpha: 1.0)
// MARK: - SIMD3<Float> Colors (for Metal shaders)
struct Metal {
static let neonMagenta = SIMD3<Float>(1.0, 0.0, 1.0)
static let neonCyan = SIMD3<Float>(0.0, 1.0, 1.0)
static let neonGreen = SIMD3<Float>(0.224, 1.0, 0.078)
static let uvViolet = SIMD3<Float>(0.482, 0.0, 1.0)
static let background = SIMD3<Float>(0.0, 0.0, 0.0)
static let darkPurple = SIMD3<Float>(0.1, 0.0, 0.15)
static let hotPink = SIMD3<Float>(1.0, 0.2, 0.6)
static let electricBlue = SIMD3<Float>(0.0, 0.5, 1.0)
/// Array of all palette colors for cycling
static let palette: [SIMD3<Float>] = [
neonMagenta,
neonCyan,
neonGreen,
uvViolet,
hotPink,
electricBlue
]
/// Get color from palette by index (wraps around)
static func color(at index: Int) -> SIMD3<Float> {
palette[index % palette.count]
}
/// Interpolate between two colors
static func lerp(_ a: SIMD3<Float>, _ b: SIMD3<Float>, t: Float) -> SIMD3<Float> {
a + (b - a) * t
}
/// Get rainbow color from normalized value (0-1)
static func rainbow(_ t: Float) -> SIMD3<Float> {
let index = Int(t * Float(palette.count))
let nextIndex = (index + 1) % palette.count
let localT = (t * Float(palette.count)) - Float(index)
return lerp(palette[index % palette.count], palette[nextIndex], t: localT)
}
}
// MARK: - Gradient Helpers
/// Creates a gradient from UV Violet through Magenta to Cyan
static var spectrumGradient: NSGradient? {
NSGradient(colors: [uvViolet, neonMagenta, hotPink, neonCyan, neonGreen])
}
/// Creates a gradient for heat maps (low to high energy)
static var heatmapGradient: NSGradient? {
NSGradient(colors: [
NSColor(red: 0.1, green: 0.0, blue: 0.2, alpha: 1.0), // Dark purple (low)
uvViolet,
neonMagenta,
hotPink,
neonCyan,
neonGreen,
NSColor.white // White (peak)
])
}
// MARK: - UI Theme Colors
struct UI {
static let panelBackground = NSColor(red: 0.05, green: 0.02, blue: 0.08, alpha: 0.9)
static let buttonBackground = NSColor(red: 0.15, green: 0.05, blue: 0.2, alpha: 1.0)
static let buttonHighlight = neonMagenta.withAlphaComponent(0.8)
static let sliderTint = neonCyan
static let labelText = NSColor.white
static let secondaryText = NSColor(white: 0.7, alpha: 1.0)
static let border = uvViolet.withAlphaComponent(0.5)
}
}
// MARK: - NSColor Extension
extension NSColor {
/// Converts NSColor to SIMD3<Float> for Metal
var simd3: SIMD3<Float> {
guard let rgb = usingColorSpace(.deviceRGB) else {
return SIMD3<Float>(0, 0, 0)
}
return SIMD3<Float>(
Float(rgb.redComponent),
Float(rgb.greenComponent),
Float(rgb.blueComponent)
)
}
/// Converts NSColor to SIMD4<Float> for Metal (with alpha)
var simd4: SIMD4<Float> {
guard let rgb = usingColorSpace(.deviceRGB) else {
return SIMD4<Float>(0, 0, 0, 1)
}
return SIMD4<Float>(
Float(rgb.redComponent),
Float(rgb.greenComponent),
Float(rgb.blueComponent),
Float(rgb.alphaComponent)
)
}
}
@@ -0,0 +1,185 @@
//
// SettingsManager.swift
// PsytranceVisualizer
//
// Handles loading and saving of application settings
//
import Foundation
import Combine
/// Manages persistent storage and retrieval of application settings
final class SettingsManager: ObservableObject {
// MARK: - Singleton
static let shared = SettingsManager()
// MARK: - Published Properties
@Published private(set) var settings: AppSettings
// MARK: - Private Properties
private let settingsKey = "PsytranceVisualizerSettings"
private let fileManager = FileManager.default
private var saveWorkItem: DispatchWorkItem?
// MARK: - Initialization
private init() {
self.settings = SettingsManager.loadSettings()
}
// MARK: - Public Methods
/// Updates settings and triggers auto-save
func updateSettings(_ update: (inout AppSettings) -> Void) {
update(&settings)
settings.validate()
scheduleSave()
}
/// Updates selected audio device
func setAudioDevice(uid: String?) {
updateSettings { $0.selectedAudioDeviceUID = uid }
}
/// Updates buffer size
func setBufferSize(_ size: Int) {
guard AppSettings.availableBufferSizes.contains(size) else { return }
updateSettings { $0.bufferSize = size }
}
/// Updates visualization mode
func setVisualizationMode(_ mode: VisualizationMode) {
updateSettings { $0.lastVisualizationMode = mode.rawValue }
}
/// Updates reactivity
func setReactivity(_ value: Float) {
updateSettings { $0.reactivity = max(0.0, min(1.0, value)) }
}
/// Updates fullscreen state
func setFullscreen(_ isFullscreen: Bool) {
updateSettings { $0.isFullscreen = isFullscreen }
}
/// Updates window frame
func setWindowFrame(_ frame: CGRect) {
updateSettings { $0.windowFrame = CodableRect(from: frame) }
}
/// Updates input gain
func setInputGain(_ gain: Float) {
updateSettings { $0.inputGain = max(0.0, min(2.0, gain)) }
}
/// Updates FPS display setting
func setShowFPS(_ show: Bool) {
updateSettings { $0.showFPS = show }
}
/// Forces immediate save
func saveNow() {
saveWorkItem?.cancel()
performSave()
}
/// Resets to default settings
func resetToDefaults() {
settings = .default
saveNow()
}
// MARK: - Private Methods
/// Schedules a debounced save operation
private func scheduleSave() {
saveWorkItem?.cancel()
let workItem = DispatchWorkItem { [weak self] in
self?.performSave()
}
saveWorkItem = workItem
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: workItem)
}
/// Performs the actual save operation
private func performSave() {
do {
let encoder = JSONEncoder()
encoder.outputFormatting = .prettyPrinted
let data = try encoder.encode(settings)
// Save to UserDefaults
UserDefaults.standard.set(data, forKey: settingsKey)
// Also save to file for backup
if let url = settingsFileURL {
try data.write(to: url)
}
print("[SettingsManager] Settings saved successfully")
} catch {
print("[SettingsManager] Failed to save settings: \(error)")
}
}
/// Loads settings from storage
private static func loadSettings() -> AppSettings {
// Try UserDefaults first
if let data = UserDefaults.standard.data(forKey: "PsytranceVisualizerSettings") {
do {
var settings = try JSONDecoder().decode(AppSettings.self, from: data)
settings.validate()
print("[SettingsManager] Settings loaded from UserDefaults")
return settings
} catch {
print("[SettingsManager] Failed to decode settings from UserDefaults: \(error)")
}
}
// Try file backup
if let url = settingsFileURL,
let data = try? Data(contentsOf: url) {
do {
var settings = try JSONDecoder().decode(AppSettings.self, from: data)
settings.validate()
print("[SettingsManager] Settings loaded from file")
return settings
} catch {
print("[SettingsManager] Failed to decode settings from file: \(error)")
}
}
print("[SettingsManager] Using default settings")
return .default
}
/// URL for settings file backup
private static var settingsFileURL: URL? {
guard let appSupport = FileManager.default.urls(
for: .applicationSupportDirectory,
in: .userDomainMask
).first else {
return nil
}
let appDirectory = appSupport.appendingPathComponent("PsytranceVisualizer")
// Create directory if needed
try? FileManager.default.createDirectory(
at: appDirectory,
withIntermediateDirectories: true
)
return appDirectory.appendingPathComponent("settings.json")
}
/// Current visualization mode
var currentVisualizationMode: VisualizationMode {
VisualizationMode(rawValue: settings.lastVisualizationMode) ?? .fftClassic
}
}
+42
View File
@@ -0,0 +1,42 @@
//
// AppDelegate.swift
// RollkofferSimulator
//
// Created by Ingo K.
//
import UIKit
@main
class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
return true
}
func applicationWillResignActive(_ application: UIApplication) {
// Pause the game when app goes to background
NotificationCenter.default.post(name: .pauseGame, object: nil)
}
func applicationDidEnterBackground(_ application: UIApplication) {
// Save game state if needed
}
func applicationWillEnterForeground(_ application: UIApplication) {
// Restore game state if needed
}
func applicationDidBecomeActive(_ application: UIApplication) {
// Resume game if needed
}
}
// MARK: - Notification Names
extension Notification.Name {
static let pauseGame = Notification.Name("pauseGame")
static let resumeGame = Notification.Name("resumeGame")
}
@@ -0,0 +1,20 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.600",
"green" : "0.300",
"red" : "0.400"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,13 @@
{
"images" : [
{
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,66 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="21701" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
<device id="retina6_12" orientation="portrait" appearance="light"/>
<dependencies>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="21678"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="EHf-IW-A2E">
<objects>
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
<rect key="frame" x="0.0" y="0.0" width="393" height="852"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="🧳" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Emoji-Label">
<rect key="frame" x="146.66666666666666" y="356" width="100" height="80"/>
<constraints>
<constraint firstAttribute="width" constant="100" id="width-constraint"/>
<constraint firstAttribute="height" constant="80" id="height-constraint"/>
</constraints>
<fontDescription key="fontDescription" type="system" pointSize="60"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Rollkoffer Simulator" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Title-Label">
<rect key="frame" x="46.666666666666657" y="456" width="300" height="30"/>
<constraints>
<constraint firstAttribute="width" constant="300" id="title-width"/>
<constraint firstAttribute="height" constant="30" id="title-height"/>
</constraints>
<fontDescription key="fontDescription" type="boldSystem" pointSize="24"/>
<color key="textColor" red="0.4" green="0.3" blue="0.6" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Created by Ingo K." textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Credits-Label">
<rect key="frame" x="96.666666666666671" y="792" width="200" height="20"/>
<constraints>
<constraint firstAttribute="width" constant="200" id="credits-width"/>
<constraint firstAttribute="height" constant="20" id="credits-height"/>
</constraints>
<fontDescription key="fontDescription" type="italicSystem" pointSize="14"/>
<color key="textColor" systemColor="secondaryLabelColor"/>
<nil key="highlightedColor"/>
</label>
</subviews>
<viewLayoutGuide key="safeArea" id="6Tk-OE-BBY"/>
<color key="backgroundColor" red="0.9" green="0.9" blue="0.85" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstItem="Emoji-Label" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="emoji-centerx"/>
<constraint firstItem="Emoji-Label" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" constant="-30" id="emoji-centery"/>
<constraint firstItem="Title-Label" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="title-centerx"/>
<constraint firstItem="Title-Label" firstAttribute="top" secondItem="Emoji-Label" secondAttribute="bottom" constant="20" id="title-top"/>
<constraint firstItem="Credits-Label" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="credits-centerx"/>
<constraint firstItem="Credits-Label" firstAttribute="bottom" secondItem="6Tk-OE-BBY" secondAttribute="bottom" constant="-20" id="credits-bottom"/>
</constraints>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="53" y="375"/>
</scene>
</scenes>
</document>
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="21701" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="BYZ-38-t0r">
<device id="retina6_12" orientation="portrait" appearance="light"/>
<dependencies>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="21678"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<!--Game View Controller-->
<scene sceneID="tne-QT-ifu">
<objects>
<viewController id="BYZ-38-t0r" customClass="GameViewController" customModule="RollkofferSimulator" customModuleProvider="target" sceneMemberID="viewController">
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC" customClass="SKView">
<rect key="frame" x="0.0" y="0.0" width="393" height="852"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<viewLayoutGuide key="safeArea" id="6Tk-OE-BBY"/>
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="-29" y="-21"/>
</scene>
</scenes>
</document>
@@ -0,0 +1,79 @@
//
// GameViewController.swift
// RollkofferSimulator
//
// Created by Ingo K.
//
import UIKit
import SpriteKit
import GameplayKit
class GameViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
// Configure the view
guard let skView = self.view as? SKView else {
fatalError("View is not an SKView")
}
// Create and configure the initial scene
let scene = MenuScene(size: skView.bounds.size)
scene.scaleMode = .aspectFill
// Configure view options
skView.ignoresSiblingOrder = true
#if DEBUG
skView.showsFPS = true
skView.showsNodeCount = true
#endif
// Present the scene
skView.presentScene(scene)
// Setup notification observers
setupNotificationObservers()
}
private func setupNotificationObservers() {
NotificationCenter.default.addObserver(
self,
selector: #selector(handlePauseNotification),
name: .pauseGame,
object: nil
)
}
@objc private func handlePauseNotification() {
guard let skView = self.view as? SKView,
let gameScene = skView.scene as? GameScene else {
return
}
// The GameScene should handle pausing internally
// This is just a notification that the app is going to background
}
override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
if UIDevice.current.userInterfaceIdiom == .phone {
return .portrait
} else {
return .all
}
}
override var prefersStatusBarHidden: Bool {
return true
}
override var preferredScreenEdgesDeferringSystemGestures: UIRectEdge {
return .all
}
deinit {
NotificationCenter.default.removeObserver(self)
}
}
+54
View File
@@ -0,0 +1,54 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Rollkoffer Simulator</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
</dict>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>armv7</string>
</array>
<key>UIRequiresFullScreen</key>
<true/>
<key>UIStatusBarHidden</key>
<true/>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>
@@ -0,0 +1,197 @@
//
// CollisionManager.swift
// RollkofferSimulator
//
// Created by Ingo K.
//
import SpriteKit
/// Protocol for collision event handling
protocol CollisionManagerDelegate: AnyObject {
func didCollectGoodDog(points: Int)
func didCollectGreenHuman(points: Int)
func didHitHarmfulEntity()
}
/// Manages collision detection and response
class CollisionManager: NSObject, SKPhysicsContactDelegate {
// MARK: - Properties
weak var delegate: CollisionManagerDelegate?
// MARK: - SKPhysicsContactDelegate
func didBegin(_ contact: SKPhysicsContact) {
let collision = contact.bodyA.categoryBitMask | contact.bodyB.categoryBitMask
// Check if suitcase is involved
guard collision & Constants.PhysicsCategory.suitcase != 0 else { return }
let otherBody: SKPhysicsBody
if contact.bodyA.categoryBitMask == Constants.PhysicsCategory.suitcase {
otherBody = contact.bodyB
} else {
otherBody = contact.bodyA
}
handleCollision(with: otherBody)
}
// MARK: - Private Methods
private func handleCollision(with body: SKPhysicsBody) {
guard let node = body.node else { return }
switch body.categoryBitMask {
case Constants.PhysicsCategory.goodDog:
handleGoodDogCollision(node: node)
case Constants.PhysicsCategory.badDog:
handleBadDogCollision(node: node)
case Constants.PhysicsCategory.greenHuman:
handleGreenHumanCollision(node: node)
case Constants.PhysicsCategory.grayHuman:
handleGrayHumanCollision(node: node)
default:
break
}
}
private func handleGoodDogCollision(node: SKNode) {
guard let dogNode = node as? DogNode else { return }
let points = dogNode.dogType.points
showCollectEffect(at: node.position, color: .green, text: "+\(points)")
node.removeFromParent()
delegate?.didCollectGoodDog(points: points)
}
private func handleBadDogCollision(node: SKNode) {
showDamageEffect(at: node.position)
node.removeFromParent()
delegate?.didHitHarmfulEntity()
}
private func handleGreenHumanCollision(node: SKNode) {
guard let humanNode = node as? HumanNode else { return }
let points = humanNode.humanType.points
showCollectEffect(at: node.position, color: .green, text: "+\(points)")
node.removeFromParent()
delegate?.didCollectGreenHuman(points: points)
}
private func handleGrayHumanCollision(node: SKNode) {
showDamageEffect(at: node.position)
node.removeFromParent()
delegate?.didHitHarmfulEntity()
}
// MARK: - Visual Effects
private func showCollectEffect(at position: CGPoint, color: SKColor, text: String) {
guard let scene = getScene() else { return }
// Particle burst
let emitter = SKEmitterNode()
emitter.particleTexture = nil
emitter.particleBirthRate = 50
emitter.numParticlesToEmit = 20
emitter.particleLifetime = 0.5
emitter.particleSpeed = 100
emitter.particleSpeedRange = 50
emitter.emissionAngleRange = .pi * 2
emitter.particleScale = 0.3
emitter.particleScaleRange = 0.2
emitter.particleColor = color
emitter.particleColorBlendFactor = 1.0
emitter.position = position
emitter.zPosition = Constants.ZPosition.ui - 1
// Create a simple circle shape for particles
let shape = SKShapeNode(circleOfRadius: 5)
shape.fillColor = color
shape.strokeColor = .clear
if let texture = scene.view?.texture(from: shape) {
emitter.particleTexture = texture
}
scene.addChild(emitter)
let waitAction = SKAction.wait(forDuration: 1.0)
let removeAction = SKAction.removeFromParent()
emitter.run(SKAction.sequence([waitAction, removeAction]))
// Floating text
let label = SKLabelNode(text: text)
label.fontName = "AvenirNext-Bold"
label.fontSize = 24
label.fontColor = color
label.position = position
label.zPosition = Constants.ZPosition.ui
scene.addChild(label)
let moveUp = SKAction.moveBy(x: 0, y: 50, duration: 0.5)
let fadeOut = SKAction.fadeOut(withDuration: 0.5)
let group = SKAction.group([moveUp, fadeOut])
let remove = SKAction.removeFromParent()
label.run(SKAction.sequence([group, remove]))
}
private func showDamageEffect(at position: CGPoint) {
guard let scene = getScene() else { return }
// Red flash
let flash = SKShapeNode(circleOfRadius: 30)
flash.fillColor = .red
flash.strokeColor = .clear
flash.alpha = 0.7
flash.position = position
flash.zPosition = Constants.ZPosition.ui - 1
scene.addChild(flash)
let scaleUp = SKAction.scale(to: 2.0, duration: 0.2)
let fadeOut = SKAction.fadeOut(withDuration: 0.2)
let group = SKAction.group([scaleUp, fadeOut])
let remove = SKAction.removeFromParent()
flash.run(SKAction.sequence([group, remove]))
// Floating text
let label = SKLabelNode(text: "-1 ❤️")
label.fontName = "AvenirNext-Bold"
label.fontSize = 24
label.fontColor = .red
label.position = position
label.zPosition = Constants.ZPosition.ui
scene.addChild(label)
let moveUp = SKAction.moveBy(x: 0, y: 50, duration: 0.5)
let labelFadeOut = SKAction.fadeOut(withDuration: 0.5)
let labelGroup = SKAction.group([moveUp, labelFadeOut])
let labelRemove = SKAction.removeFromParent()
label.run(SKAction.sequence([labelGroup, labelRemove]))
}
private func getScene() -> SKScene? {
// This would typically be set via dependency injection
// For simplicity, we'll use the notification pattern
return nil
}
}
// MARK: - Scene Reference Extension
extension CollisionManager {
private static var sceneReference: SKScene?
func setScene(_ scene: SKScene) {
CollisionManager.sceneReference = scene
}
private func getSceneFromReference() -> SKScene? {
return CollisionManager.sceneReference
}
}
@@ -0,0 +1,91 @@
//
// ScoreManager.swift
// RollkofferSimulator
//
// Created by Ingo K.
//
import Foundation
/// Manages high scores and game statistics
class ScoreManager {
// MARK: - Singleton
static let shared = ScoreManager()
// MARK: - UserDefaults Keys
private let highScoreKey = "RollkofferSimulator.HighScore"
private let gamesPlayedKey = "RollkofferSimulator.GamesPlayed"
private let totalDogsCollectedKey = "RollkofferSimulator.TotalDogsCollected"
private let totalHumansCollectedKey = "RollkofferSimulator.TotalHumansCollected"
private let victoriesKey = "RollkofferSimulator.Victories"
// MARK: - Properties
private let defaults = UserDefaults.standard
var highScore: Int {
get { defaults.integer(forKey: highScoreKey) }
set { defaults.set(newValue, forKey: highScoreKey) }
}
var gamesPlayed: Int {
get { defaults.integer(forKey: gamesPlayedKey) }
set { defaults.set(newValue, forKey: gamesPlayedKey) }
}
var totalDogsCollected: Int {
get { defaults.integer(forKey: totalDogsCollectedKey) }
set { defaults.set(newValue, forKey: totalDogsCollectedKey) }
}
var totalHumansCollected: Int {
get { defaults.integer(forKey: totalHumansCollectedKey) }
set { defaults.set(newValue, forKey: totalHumansCollectedKey) }
}
var victories: Int {
get { defaults.integer(forKey: victoriesKey) }
set { defaults.set(newValue, forKey: victoriesKey) }
}
// MARK: - Initialization
private init() {}
// MARK: - Public Methods
func recordGameEnd(score: Int, dogsCollected: Int, humansCollected: Int, didWin: Bool) {
gamesPlayed += 1
totalDogsCollected += dogsCollected
totalHumansCollected += humansCollected
if score > highScore {
highScore = score
}
if didWin {
victories += 1
}
}
func isNewHighScore(_ score: Int) -> Bool {
return score > highScore
}
func resetStatistics() {
highScore = 0
gamesPlayed = 0
totalDogsCollected = 0
totalHumansCollected = 0
victories = 0
}
// MARK: - Formatted Statistics
func getStatisticsText() -> String {
return """
High Score: \(highScore)
Games Played: \(gamesPlayed)
Victories: \(victories)
Total Dogs: \(totalDogsCollected)
Total Humans: \(totalHumansCollected)
"""
}
}
@@ -0,0 +1,104 @@
//
// SpawnManager.swift
// RollkofferSimulator
//
// Created by Ingo K.
//
import SpriteKit
/// Manages spawning of entities at the top of the screen
class SpawnManager {
// MARK: - Properties
private weak var scene: SKScene?
private var spawnTimer: TimeInterval = 0
private var nextSpawnInterval: TimeInterval = 0
private var isSpawning: Bool = false
// MARK: - Initialization
init(scene: SKScene) {
self.scene = scene
resetSpawnInterval()
}
// MARK: - Public Methods
func startSpawning() {
isSpawning = true
resetSpawnInterval()
}
func stopSpawning() {
isSpawning = false
}
func update(deltaTime: TimeInterval) {
guard isSpawning else { return }
spawnTimer += deltaTime
if spawnTimer >= nextSpawnInterval {
spawnEntity()
spawnTimer = 0
resetSpawnInterval()
}
}
// MARK: - Private Methods
private func resetSpawnInterval() {
nextSpawnInterval = TimeInterval.random(in: Constants.spawnIntervalMin...Constants.spawnIntervalMax)
}
private func spawnEntity() {
guard let scene = scene else { return }
let entityType = determineEntityType()
let entity = createEntity(type: entityType)
// Random x position
let margin: CGFloat = 60
let minX = margin
let maxX = scene.frame.width - margin
let randomX = CGFloat.random(in: minX...maxX)
// Spawn above screen
entity.position = CGPoint(x: randomX, y: scene.frame.height + 50)
scene.addChild(entity)
// Move entity down
let moveDistance = scene.frame.height + 200
let moveDuration = moveDistance / Constants.scrollSpeed
let moveAction = SKAction.moveBy(x: 0, y: -moveDistance, duration: moveDuration)
let removeAction = SKAction.removeFromParent()
entity.run(SKAction.sequence([moveAction, removeAction]))
}
private func determineEntityType() -> EntityType {
let roll = Int.random(in: 0..<100)
if roll < Constants.spawnChanceGoodDog {
// 40% good dogs (split between small and big)
let isSmall = Bool.random()
return .dog(isSmall ? .smallGood : .bigGood)
} else if roll < Constants.spawnChanceBadDog {
// 20% bad dogs
return .dog(.bad)
} else if roll < Constants.spawnChanceGreenHuman {
// 25% green humans
return .human(.green)
} else {
// 15% gray humans
return .human(.gray)
}
}
private func createEntity(type: EntityType) -> SKNode {
switch type {
case .dog(let dogType):
return DogNode(type: dogType)
case .human(let humanType):
return HumanNode(type: humanType)
}
}
}
@@ -0,0 +1,58 @@
//
// EntityType.swift
// RollkofferSimulator
//
// Created by Ingo K.
//
import Foundation
/// Types of dogs in the game
enum DogType {
case smallGood // 40x40, gold brown, +10 points
case bigGood // 70x70, gold brown, +25 points
case bad // 55x55, red outlined, -1 life
var points: Int {
switch self {
case .smallGood: return Constants.pointsSmallGoodDog
case .bigGood: return Constants.pointsBigGoodDog
case .bad: return 0
}
}
var isHarmful: Bool {
return self == .bad
}
var countsTowardGoal: Bool {
return self == .smallGood || self == .bigGood
}
}
/// Types of humans in the game
enum HumanType {
case green // 50x80, green, +15 points
case gray // 50x80, gray, -1 life
var points: Int {
switch self {
case .green: return Constants.pointsGreenHuman
case .gray: return 0
}
}
var isHarmful: Bool {
return self == .gray
}
var countsTowardGoal: Bool {
return self == .green
}
}
/// All entity types for spawn system
enum EntityType {
case dog(DogType)
case human(HumanType)
}
+103
View File
@@ -0,0 +1,103 @@
//
// GameState.swift
// RollkofferSimulator
//
// Created by Ingo K.
//
import Foundation
/// Possible game states
enum GameStateType {
case startScreen
case playing
case paused
case gameOver
case victory
}
/// Manages the current game state and statistics
class GameState {
// MARK: - Properties
private(set) var currentState: GameStateType = .startScreen
private(set) var score: Int = 0
private(set) var lives: Int = Constants.startLives
private(set) var dogsCollected: Int = 0
private(set) var humansCollected: Int = 0
private(set) var timeRemaining: TimeInterval = Constants.gameTime
var isInvincible: Bool = false
// MARK: - Computed Properties
var hasWon: Bool {
return dogsCollected >= Constants.targetDogs &&
humansCollected >= Constants.targetHumans
}
var hasLost: Bool {
return lives <= 0 || (timeRemaining <= 0 && !hasWon)
}
// MARK: - State Management
func setState(_ state: GameStateType) {
currentState = state
}
func reset() {
score = 0
lives = Constants.startLives
dogsCollected = 0
humansCollected = 0
timeRemaining = Constants.gameTime
isInvincible = false
currentState = .playing
}
// MARK: - Score Management
func addPoints(_ points: Int) {
score += points
}
func collectDog() {
dogsCollected += 1
}
func collectHuman() {
humansCollected += 1
}
// MARK: - Lives Management
func loseLife() -> Bool {
guard !isInvincible else { return false }
lives -= 1
return true
}
// MARK: - Time Management
func updateTime(delta: TimeInterval) {
timeRemaining = max(0, timeRemaining - delta)
}
// MARK: - Formatted Strings
var formattedTime: String {
let seconds = Int(timeRemaining)
return "\(seconds)s"
}
var formattedScore: String {
return "SCORE: \(score)"
}
var formattedDogs: String {
return "🐕 \(dogsCollected)/\(Constants.targetDogs)"
}
var formattedHumans: String {
return "👤 \(humansCollected)/\(Constants.targetHumans)"
}
var formattedLives: String {
return String(repeating: "❤️", count: lives)
}
}
+162
View File
@@ -0,0 +1,162 @@
//
// DogNode.swift
// RollkofferSimulator
//
// Created by Ingo K.
//
import SpriteKit
/// A dog entity node
class DogNode: SKNode {
// MARK: - Properties
let dogType: DogType
private let bodyNode: SKShapeNode
// MARK: - Initialization
init(type: DogType) {
self.dogType = type
let size: CGSize
let color: SKColor
let hasRedOutline: Bool
switch type {
case .smallGood:
size = Constants.smallDogSize
color = Constants.Colors.goodDogColor
hasRedOutline = false
case .bigGood:
size = Constants.bigDogSize
color = Constants.Colors.goodDogColor
hasRedOutline = false
case .bad:
size = Constants.badDogSize
color = Constants.Colors.goodDogColor
hasRedOutline = true
}
// Create dog body (simple oval shape)
let bodyPath = CGMutablePath()
bodyPath.addEllipse(in: CGRect(x: -size.width / 2, y: -size.height / 2,
width: size.width, height: size.height * 0.7))
bodyNode = SKShapeNode(path: bodyPath)
bodyNode.fillColor = color
bodyNode.strokeColor = hasRedOutline ? Constants.Colors.badDogColor : SKColor(white: 0.3, alpha: 1.0)
bodyNode.lineWidth = hasRedOutline ? 4 : 2
super.init()
addChild(bodyNode)
// Add head
let headSize = size.width * 0.5
let head = SKShapeNode(circleOfRadius: headSize / 2)
head.position = CGPoint(x: 0, y: size.height * 0.25)
head.fillColor = color
head.strokeColor = hasRedOutline ? Constants.Colors.badDogColor : SKColor(white: 0.3, alpha: 1.0)
head.lineWidth = hasRedOutline ? 3 : 1.5
addChild(head)
// Add ears
let earSize = headSize * 0.4
for xOffset in [-headSize * 0.4, headSize * 0.4] {
let ear = SKShapeNode(ellipseOf: CGSize(width: earSize, height: earSize * 1.5))
ear.position = CGPoint(x: xOffset, y: size.height * 0.25 + headSize * 0.35)
ear.fillColor = color.withAlphaComponent(0.8)
ear.strokeColor = hasRedOutline ? Constants.Colors.badDogColor : SKColor(white: 0.3, alpha: 1.0)
ear.lineWidth = hasRedOutline ? 2 : 1
addChild(ear)
}
// Add eyes
let eyeSize: CGFloat = headSize * 0.15
for xOffset in [-headSize * 0.15, headSize * 0.15] {
let eye = SKShapeNode(circleOfRadius: eyeSize)
eye.position = CGPoint(x: xOffset, y: size.height * 0.28)
eye.fillColor = hasRedOutline ? .red : .black
eye.strokeColor = .clear
addChild(eye)
}
// Add nose
let nose = SKShapeNode(circleOfRadius: headSize * 0.1)
nose.position = CGPoint(x: 0, y: size.height * 0.18)
nose.fillColor = .black
nose.strokeColor = .clear
addChild(nose)
// Add tail
let tailPath = CGMutablePath()
tailPath.move(to: CGPoint(x: 0, y: -size.height * 0.25))
tailPath.addQuadCurve(to: CGPoint(x: size.width * 0.3, y: -size.height * 0.1),
control: CGPoint(x: size.width * 0.4, y: -size.height * 0.3))
let tail = SKShapeNode(path: tailPath)
tail.strokeColor = color
tail.lineWidth = size.width * 0.1
tail.lineCap = .round
addChild(tail)
// Add legs
let legWidth = size.width * 0.12
let legHeight = size.height * 0.25
let legPositions: [CGFloat] = [-size.width * 0.25, -size.width * 0.1,
size.width * 0.1, size.width * 0.25]
for xPos in legPositions {
let leg = SKShapeNode(rect: CGRect(x: xPos - legWidth / 2,
y: -size.height * 0.35 - legHeight,
width: legWidth, height: legHeight),
cornerRadius: legWidth / 2)
leg.fillColor = color
leg.strokeColor = hasRedOutline ? Constants.Colors.badDogColor : SKColor(white: 0.3, alpha: 1.0)
leg.lineWidth = hasRedOutline ? 2 : 1
addChild(leg)
}
// Add angry expression for bad dogs
if hasRedOutline {
let browPath = CGMutablePath()
browPath.move(to: CGPoint(x: -headSize * 0.3, y: size.height * 0.35))
browPath.addLine(to: CGPoint(x: -headSize * 0.05, y: size.height * 0.32))
let leftBrow = SKShapeNode(path: browPath)
leftBrow.strokeColor = .black
leftBrow.lineWidth = 2
addChild(leftBrow)
let browPath2 = CGMutablePath()
browPath2.move(to: CGPoint(x: headSize * 0.3, y: size.height * 0.35))
browPath2.addLine(to: CGPoint(x: headSize * 0.05, y: size.height * 0.32))
let rightBrow = SKShapeNode(path: browPath2)
rightBrow.strokeColor = .black
rightBrow.lineWidth = 2
addChild(rightBrow)
}
setupPhysics(size: size)
self.zPosition = Constants.ZPosition.entities
self.name = type.isHarmful ? "badDog" : "goodDog"
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: - Physics Setup
private func setupPhysics(size: CGSize) {
let radius = min(size.width, size.height) / 2
physicsBody = SKPhysicsBody(circleOfRadius: radius)
physicsBody?.isDynamic = true
physicsBody?.affectedByGravity = false
physicsBody?.allowsRotation = false
if dogType.isHarmful {
physicsBody?.categoryBitMask = Constants.PhysicsCategory.badDog
} else {
physicsBody?.categoryBitMask = Constants.PhysicsCategory.goodDog
}
physicsBody?.contactTestBitMask = Constants.PhysicsCategory.suitcase
physicsBody?.collisionBitMask = Constants.PhysicsCategory.none
}
}
+176
View File
@@ -0,0 +1,176 @@
//
// HumanNode.swift
// RollkofferSimulator
//
// Created by Ingo K.
//
import SpriteKit
/// A human entity node
class HumanNode: SKNode {
// MARK: - Properties
let humanType: HumanType
private let bodyNode: SKShapeNode
// MARK: - Initialization
init(type: HumanType) {
self.humanType = type
let size = Constants.humanSize
let color: SKColor
switch type {
case .green:
color = Constants.Colors.greenHumanColor
case .gray:
color = Constants.Colors.grayHumanColor
}
// Create body (torso)
let torsoHeight = size.height * 0.4
let torsoWidth = size.width * 0.6
let torsoRect = CGRect(x: -torsoWidth / 2, y: -size.height * 0.1,
width: torsoWidth, height: torsoHeight)
bodyNode = SKShapeNode(rect: torsoRect, cornerRadius: 5)
bodyNode.fillColor = color
bodyNode.strokeColor = color.withAlphaComponent(0.7)
bodyNode.lineWidth = 2
super.init()
addChild(bodyNode)
// Add head
let headRadius = size.width * 0.25
let head = SKShapeNode(circleOfRadius: headRadius)
head.position = CGPoint(x: 0, y: size.height * 0.35)
head.fillColor = SKColor(red: 1.0, green: 0.87, blue: 0.77, alpha: 1.0) // Skin tone
head.strokeColor = SKColor(red: 0.9, green: 0.75, blue: 0.65, alpha: 1.0)
head.lineWidth = 1
addChild(head)
// Add eyes
let eyeSize: CGFloat = 3
for xOffset in [-headRadius * 0.35, headRadius * 0.35] {
let eye = SKShapeNode(circleOfRadius: eyeSize)
eye.position = CGPoint(x: xOffset, y: size.height * 0.37)
eye.fillColor = .black
eye.strokeColor = .clear
addChild(eye)
}
// Add mouth/expression
if type == .green {
// Happy smile
let smilePath = CGMutablePath()
smilePath.addArc(center: CGPoint(x: 0, y: size.height * 0.32),
radius: headRadius * 0.3,
startAngle: .pi * 0.2,
endAngle: .pi * 0.8,
clockwise: true)
let smile = SKShapeNode(path: smilePath)
smile.strokeColor = .black
smile.lineWidth = 2
smile.lineCap = .round
addChild(smile)
} else {
// Neutral/frowning expression
let frownPath = CGMutablePath()
frownPath.move(to: CGPoint(x: -headRadius * 0.25, y: size.height * 0.3))
frownPath.addLine(to: CGPoint(x: headRadius * 0.25, y: size.height * 0.3))
let frown = SKShapeNode(path: frownPath)
frown.strokeColor = .black
frown.lineWidth = 2
addChild(frown)
}
// Add arms
let armWidth: CGFloat = 6
let armLength = size.height * 0.3
for xOffset in [-torsoWidth / 2 - armWidth / 2, torsoWidth / 2 + armWidth / 2] {
let arm = SKShapeNode(rect: CGRect(x: xOffset - armWidth / 2,
y: size.height * 0.05,
width: armWidth, height: armLength),
cornerRadius: armWidth / 2)
arm.fillColor = color
arm.strokeColor = color.withAlphaComponent(0.7)
arm.lineWidth = 1
addChild(arm)
// Add hand
let hand = SKShapeNode(circleOfRadius: armWidth * 0.8)
hand.position = CGPoint(x: xOffset, y: size.height * 0.05)
hand.fillColor = SKColor(red: 1.0, green: 0.87, blue: 0.77, alpha: 1.0)
hand.strokeColor = .clear
addChild(hand)
}
// Add legs
let legWidth: CGFloat = 10
let legHeight = size.height * 0.35
for xOffset in [-torsoWidth * 0.25, torsoWidth * 0.25] {
let leg = SKShapeNode(rect: CGRect(x: xOffset - legWidth / 2,
y: -size.height * 0.45,
width: legWidth, height: legHeight),
cornerRadius: legWidth / 2)
leg.fillColor = SKColor(red: 0.2, green: 0.2, blue: 0.4, alpha: 1.0) // Pants color
leg.strokeColor = SKColor(red: 0.15, green: 0.15, blue: 0.3, alpha: 1.0)
leg.lineWidth = 1
addChild(leg)
// Add shoe
let shoe = SKShapeNode(rect: CGRect(x: xOffset - legWidth * 0.6,
y: -size.height * 0.48,
width: legWidth * 1.2, height: 6),
cornerRadius: 2)
shoe.fillColor = .black
shoe.strokeColor = .clear
addChild(shoe)
}
// Add indicator icon for type
if type == .green {
let checkmark = SKLabelNode(text: "")
checkmark.fontSize = 16
checkmark.fontColor = .white
checkmark.position = CGPoint(x: 0, y: size.height * 0.1)
checkmark.verticalAlignmentMode = .center
addChild(checkmark)
} else {
let xMark = SKLabelNode(text: "")
xMark.fontSize = 16
xMark.fontColor = .white
xMark.position = CGPoint(x: 0, y: size.height * 0.1)
xMark.verticalAlignmentMode = .center
addChild(xMark)
}
setupPhysics(size: size)
self.zPosition = Constants.ZPosition.entities
self.name = type.isHarmful ? "grayHuman" : "greenHuman"
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: - Physics Setup
private func setupPhysics(size: CGSize) {
physicsBody = SKPhysicsBody(rectangleOf: CGSize(width: size.width * 0.6,
height: size.height * 0.8))
physicsBody?.isDynamic = true
physicsBody?.affectedByGravity = false
physicsBody?.allowsRotation = false
if humanType.isHarmful {
physicsBody?.categoryBitMask = Constants.PhysicsCategory.grayHuman
} else {
physicsBody?.categoryBitMask = Constants.PhysicsCategory.greenHuman
}
physicsBody?.contactTestBitMask = Constants.PhysicsCategory.suitcase
physicsBody?.collisionBitMask = Constants.PhysicsCategory.none
}
}
+126
View File
@@ -0,0 +1,126 @@
//
// PlayerNode.swift
// RollkofferSimulator
//
// Created by Ingo K.
//
import SpriteKit
/// The player-controlled suitcase node
class PlayerNode: SKNode {
// MARK: - Properties
private let suitcaseBody: SKShapeNode
private let handle: SKShapeNode
private let wheels: [SKShapeNode]
// MARK: - Initialization
override init() {
let size = Constants.suitcaseSize
// Create suitcase body (rounded rectangle)
let bodyRect = CGRect(x: -size.width / 2, y: -size.height / 2 + 10,
width: size.width, height: size.height - 15)
suitcaseBody = SKShapeNode(rect: bodyRect, cornerRadius: 8)
suitcaseBody.fillColor = Constants.Colors.suitcaseColor
suitcaseBody.strokeColor = SKColor(white: 0.2, alpha: 1.0)
suitcaseBody.lineWidth = 2
// Create handle
let handlePath = CGMutablePath()
handlePath.move(to: CGPoint(x: -10, y: size.height / 2 - 5))
handlePath.addLine(to: CGPoint(x: -10, y: size.height / 2 + 15))
handlePath.addLine(to: CGPoint(x: 10, y: size.height / 2 + 15))
handlePath.addLine(to: CGPoint(x: 10, y: size.height / 2 - 5))
handle = SKShapeNode(path: handlePath)
handle.strokeColor = SKColor(white: 0.3, alpha: 1.0)
handle.lineWidth = 4
handle.lineCap = .round
// Create wheels
var tempWheels: [SKShapeNode] = []
let wheelPositions = [
CGPoint(x: -size.width / 2 + 8, y: -size.height / 2 + 5),
CGPoint(x: size.width / 2 - 8, y: -size.height / 2 + 5)
]
for pos in wheelPositions {
let wheel = SKShapeNode(circleOfRadius: 6)
wheel.position = pos
wheel.fillColor = SKColor.darkGray
wheel.strokeColor = SKColor.black
wheel.lineWidth = 1
tempWheels.append(wheel)
}
wheels = tempWheels
super.init()
// Add decorative stripes
let stripe1 = SKShapeNode(rect: CGRect(x: -size.width / 2 + 5, y: 0,
width: size.width - 10, height: 3))
stripe1.fillColor = SKColor(white: 0.3, alpha: 0.5)
stripe1.strokeColor = .clear
let stripe2 = SKShapeNode(rect: CGRect(x: -size.width / 2 + 5, y: -15,
width: size.width - 10, height: 3))
stripe2.fillColor = SKColor(white: 0.3, alpha: 0.5)
stripe2.strokeColor = .clear
addChild(suitcaseBody)
addChild(handle)
addChild(stripe1)
addChild(stripe2)
for wheel in wheels {
addChild(wheel)
}
setupPhysics()
self.zPosition = Constants.ZPosition.player
self.name = "player"
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: - Physics Setup
private func setupPhysics() {
let size = Constants.suitcaseSize
physicsBody = SKPhysicsBody(rectangleOf: size)
physicsBody?.isDynamic = true
physicsBody?.affectedByGravity = false
physicsBody?.allowsRotation = false
physicsBody?.categoryBitMask = Constants.PhysicsCategory.suitcase
physicsBody?.contactTestBitMask = Constants.PhysicsCategory.all
physicsBody?.collisionBitMask = Constants.PhysicsCategory.none
}
// MARK: - Visual Effects
func startBlinking() {
let fadeOut = SKAction.fadeAlpha(to: 0.3, duration: Constants.blinkDuration)
let fadeIn = SKAction.fadeAlpha(to: 1.0, duration: Constants.blinkDuration)
let blink = SKAction.sequence([fadeOut, fadeIn])
let blinkRepeat = SKAction.repeat(blink, count: Constants.blinkCount)
run(blinkRepeat, withKey: "blink")
}
func stopBlinking() {
removeAction(forKey: "blink")
alpha = 1.0
}
// MARK: - Movement
func constrainToScreen(in frame: CGRect) {
let halfWidth = Constants.suitcaseSize.width / 2
let halfHeight = Constants.suitcaseSize.height / 2
let minX = frame.minX + halfWidth + 10
let maxX = frame.maxX - halfWidth - 10
let minY = frame.minY + halfHeight + 10
let maxY = frame.maxY - halfHeight - 100 // Leave space for UI
position.x = max(minX, min(maxX, position.x))
position.y = max(minY, min(maxY, position.y))
}
}
@@ -0,0 +1,457 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 56;
objects = {
/* Begin PBXBuildFile section */
001 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 101 /* AppDelegate.swift */; };
002 /* GameViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 102 /* GameViewController.swift */; };
003 /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 103 /* Constants.swift */; };
004 /* EntityType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 104 /* EntityType.swift */; };
005 /* GameState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 105 /* GameState.swift */; };
006 /* PlayerNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 106 /* PlayerNode.swift */; };
007 /* DogNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 107 /* DogNode.swift */; };
008 /* HumanNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 108 /* HumanNode.swift */; };
009 /* SpawnManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 109 /* SpawnManager.swift */; };
010 /* CollisionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 110 /* CollisionManager.swift */; };
011 /* ScoreManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 111 /* ScoreManager.swift */; };
012 /* MenuScene.swift in Sources */ = {isa = PBXBuildFile; fileRef = 112 /* MenuScene.swift */; };
013 /* GameScene.swift in Sources */ = {isa = PBXBuildFile; fileRef = 113 /* GameScene.swift */; };
014 /* GameOverScene.swift in Sources */ = {isa = PBXBuildFile; fileRef = 114 /* GameOverScene.swift */; };
015 /* VictoryScene.swift in Sources */ = {isa = PBXBuildFile; fileRef = 115 /* VictoryScene.swift */; };
016 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 116 /* Assets.xcassets */; };
017 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 117 /* Main.storyboard */; };
018 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 118 /* LaunchScreen.storyboard */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
100 /* RollkofferSimulator.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = RollkofferSimulator.app; sourceTree = BUILT_PRODUCTS_DIR; };
101 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
102 /* GameViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameViewController.swift; sourceTree = "<group>"; };
103 /* Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Constants.swift; sourceTree = "<group>"; };
104 /* EntityType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EntityType.swift; sourceTree = "<group>"; };
105 /* GameState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameState.swift; sourceTree = "<group>"; };
106 /* PlayerNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerNode.swift; sourceTree = "<group>"; };
107 /* DogNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DogNode.swift; sourceTree = "<group>"; };
108 /* HumanNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HumanNode.swift; sourceTree = "<group>"; };
109 /* SpawnManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpawnManager.swift; sourceTree = "<group>"; };
110 /* CollisionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollisionManager.swift; sourceTree = "<group>"; };
111 /* ScoreManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScoreManager.swift; sourceTree = "<group>"; };
112 /* MenuScene.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuScene.swift; sourceTree = "<group>"; };
113 /* GameScene.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameScene.swift; sourceTree = "<group>"; };
114 /* GameOverScene.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameOverScene.swift; sourceTree = "<group>"; };
115 /* VictoryScene.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VictoryScene.swift; sourceTree = "<group>"; };
116 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
117 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
118 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
119 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
200 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
300 = {
isa = PBXGroup;
children = (
301 /* RollkofferSimulator */,
302 /* Products */,
);
sourceTree = "<group>";
};
301 /* RollkofferSimulator */ = {
isa = PBXGroup;
children = (
101 /* AppDelegate.swift */,
102 /* GameViewController.swift */,
303 /* Scenes */,
304 /* Nodes */,
305 /* Managers */,
306 /* Models */,
307 /* Utils */,
116 /* Assets.xcassets */,
117 /* Main.storyboard */,
118 /* LaunchScreen.storyboard */,
119 /* Info.plist */,
);
path = .;
sourceTree = "<group>";
};
302 /* Products */ = {
isa = PBXGroup;
children = (
100 /* RollkofferSimulator.app */,
);
name = Products;
sourceTree = "<group>";
};
303 /* Scenes */ = {
isa = PBXGroup;
children = (
112 /* MenuScene.swift */,
113 /* GameScene.swift */,
114 /* GameOverScene.swift */,
115 /* VictoryScene.swift */,
);
path = Scenes;
sourceTree = "<group>";
};
304 /* Nodes */ = {
isa = PBXGroup;
children = (
106 /* PlayerNode.swift */,
107 /* DogNode.swift */,
108 /* HumanNode.swift */,
);
path = Nodes;
sourceTree = "<group>";
};
305 /* Managers */ = {
isa = PBXGroup;
children = (
109 /* SpawnManager.swift */,
110 /* CollisionManager.swift */,
111 /* ScoreManager.swift */,
);
path = Managers;
sourceTree = "<group>";
};
306 /* Models */ = {
isa = PBXGroup;
children = (
104 /* EntityType.swift */,
105 /* GameState.swift */,
);
path = Models;
sourceTree = "<group>";
};
307 /* Utils */ = {
isa = PBXGroup;
children = (
103 /* Constants.swift */,
);
path = Utils;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
400 /* RollkofferSimulator */ = {
isa = PBXNativeTarget;
buildConfigurationList = 501 /* Build configuration list for PBXNativeTarget "RollkofferSimulator" */;
buildPhases = (
401 /* Sources */,
200 /* Frameworks */,
402 /* Resources */,
);
buildRules = (
);
dependencies = (
);
name = RollkofferSimulator;
productName = RollkofferSimulator;
productReference = 100 /* RollkofferSimulator.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
600 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 1500;
LastUpgradeCheck = 1500;
TargetAttributes = {
400 = {
CreatedOnToolsVersion = 15.0;
};
};
};
buildConfigurationList = 500 /* Build configuration list for PBXProject "RollkofferSimulator" */;
compatibilityVersion = "Xcode 14.0";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
de,
);
mainGroup = 300;
productRefGroup = 302 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
400 /* RollkofferSimulator */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
402 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
016 /* Assets.xcassets in Resources */,
017 /* Main.storyboard in Resources */,
018 /* LaunchScreen.storyboard in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
401 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
001 /* AppDelegate.swift in Sources */,
002 /* GameViewController.swift in Sources */,
003 /* Constants.swift in Sources */,
004 /* EntityType.swift in Sources */,
005 /* GameState.swift in Sources */,
006 /* PlayerNode.swift in Sources */,
007 /* DogNode.swift in Sources */,
008 /* HumanNode.swift in Sources */,
009 /* SpawnManager.swift in Sources */,
010 /* CollisionManager.swift in Sources */,
011 /* ScoreManager.swift in Sources */,
012 /* MenuScene.swift in Sources */,
013 /* GameScene.swift in Sources */,
014 /* GameOverScene.swift in Sources */,
015 /* VictoryScene.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXVariantGroup section */
117 /* Main.storyboard */ = {
isa = PBXVariantGroup;
children = (
117 /* Base */,
);
name = Main.storyboard;
sourceTree = "<group>";
};
118 /* LaunchScreen.storyboard */ = {
isa = PBXVariantGroup;
children = (
118 /* Base */,
);
name = LaunchScreen.storyboard;
sourceTree = "<group>";
};
/* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */
700 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = Debug;
};
701 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
VALIDATE_PRODUCT = YES;
};
name = Release;
};
702 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Info.plist;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
INFOPLIST_KEY_UIMainStoryboardFile = Main;
INFOPLIST_KEY_UIStatusBarHidden = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.ingok.RollkofferSimulator;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
703 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Info.plist;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
INFOPLIST_KEY_UIMainStoryboardFile = Main;
INFOPLIST_KEY_UIStatusBarHidden = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.ingok.RollkofferSimulator;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
500 /* Build configuration list for PBXProject "RollkofferSimulator" */ = {
isa = XCConfigurationList;
buildConfigurations = (
700 /* Debug */,
701 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
501 /* Build configuration list for PBXNativeTarget "RollkofferSimulator" */ = {
isa = XCConfigurationList;
buildConfigurations = (
702 /* Debug */,
703 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 600 /* Project object */;
}
@@ -0,0 +1,255 @@
//
// GameOverScene.swift
// RollkofferSimulator
//
// Created by Ingo K.
//
import SpriteKit
class GameOverScene: SKScene {
// MARK: - Properties
var finalScore: Int = 0
var dogsCollected: Int = 0
var humansCollected: Int = 0
var isNewHighScore: Bool = false
private var retryButton: SKShapeNode!
private var menuButton: SKShapeNode!
// MARK: - Scene Lifecycle
override func didMove(to view: SKView) {
setupBackground()
setupContent()
setupButtons()
startAnimations()
}
// MARK: - Setup
private func setupBackground() {
backgroundColor = SKColor(red: 0.15, green: 0.1, blue: 0.1, alpha: 1.0)
// Add dim pattern
for i in 0..<20 {
let x = CGFloat.random(in: 0...frame.width)
let y = CGFloat.random(in: 0...frame.height)
let size = CGFloat.random(in: 20...60)
let shape = SKShapeNode(circleOfRadius: size)
shape.position = CGPoint(x: x, y: y)
shape.fillColor = SKColor.red.withAlphaComponent(0.05)
shape.strokeColor = .clear
shape.zPosition = 0.1
addChild(shape)
}
}
private func setupContent() {
// Game Over title
let titleLabel = SKLabelNode(text: "💔 GAME OVER 💔")
titleLabel.fontName = "AvenirNext-Heavy"
titleLabel.fontSize = 42
titleLabel.fontColor = .red
titleLabel.position = CGPoint(x: frame.midX, y: frame.height * 0.8)
titleLabel.zPosition = Constants.ZPosition.ui
addChild(titleLabel)
// Subtitle based on reason
let subtitleText: String
if dogsCollected < Constants.targetDogs || humansCollected < Constants.targetHumans {
subtitleText = "Zeit abgelaufen!"
} else {
subtitleText = "Keine Leben mehr!"
}
let subtitleLabel = SKLabelNode(text: subtitleText)
subtitleLabel.fontName = "AvenirNext-Medium"
subtitleLabel.fontSize = 22
subtitleLabel.fontColor = SKColor(white: 0.8, alpha: 1.0)
subtitleLabel.position = CGPoint(x: frame.midX, y: frame.height * 0.72)
subtitleLabel.zPosition = Constants.ZPosition.ui
addChild(subtitleLabel)
// Score display
let scoreLabel = SKLabelNode(text: "Punkte: \(finalScore)")
scoreLabel.fontName = "AvenirNext-Bold"
scoreLabel.fontSize = 32
scoreLabel.fontColor = .white
scoreLabel.position = CGPoint(x: frame.midX, y: frame.height * 0.58)
scoreLabel.zPosition = Constants.ZPosition.ui
addChild(scoreLabel)
// New high score indicator
if isNewHighScore {
let highScoreLabel = SKLabelNode(text: "🏆 NEUER HIGHSCORE! 🏆")
highScoreLabel.fontName = "AvenirNext-Heavy"
highScoreLabel.fontSize = 24
highScoreLabel.fontColor = SKColor.yellow
highScoreLabel.position = CGPoint(x: frame.midX, y: frame.height * 0.65)
highScoreLabel.zPosition = Constants.ZPosition.ui
addChild(highScoreLabel)
// Animate high score
let scale = SKAction.sequence([
SKAction.scale(to: 1.1, duration: 0.5),
SKAction.scale(to: 1.0, duration: 0.5)
])
highScoreLabel.run(SKAction.repeatForever(scale))
}
// Stats display
let dogsText = "🐕 \(dogsCollected)/\(Constants.targetDogs)"
let humansText = "👤 \(humansCollected)/\(Constants.targetHumans)"
let statsLabel = SKLabelNode(text: "\(dogsText) | \(humansText)")
statsLabel.fontName = "AvenirNext-Medium"
statsLabel.fontSize = 24
statsLabel.fontColor = SKColor(white: 0.7, alpha: 1.0)
statsLabel.position = CGPoint(x: frame.midX, y: frame.height * 0.48)
statsLabel.zPosition = Constants.ZPosition.ui
addChild(statsLabel)
// Progress indicators
let dogsProgress = min(1.0, CGFloat(dogsCollected) / CGFloat(Constants.targetDogs))
let humansProgress = min(1.0, CGFloat(humansCollected) / CGFloat(Constants.targetHumans))
addProgressBar(at: CGPoint(x: frame.midX - 60, y: frame.height * 0.42),
progress: dogsProgress, color: .orange, label: "Hunde")
addProgressBar(at: CGPoint(x: frame.midX + 60, y: frame.height * 0.42),
progress: humansProgress, color: .green, label: "Menschen")
// Sad suitcase
let sadSuitcase = PlayerNode()
sadSuitcase.position = CGPoint(x: frame.midX, y: frame.height * 0.25)
sadSuitcase.alpha = 0.6
sadSuitcase.setScale(0.8)
addChild(sadSuitcase)
// Sad face on suitcase area
let sadFace = SKLabelNode(text: "😢")
sadFace.fontSize = 30
sadFace.position = CGPoint(x: frame.midX, y: frame.height * 0.25 + 20)
sadFace.zPosition = Constants.ZPosition.ui
addChild(sadFace)
}
private func addProgressBar(at position: CGPoint, progress: CGFloat, color: SKColor, label: String) {
let barWidth: CGFloat = 80
let barHeight: CGFloat = 12
// Background
let bg = SKShapeNode(rect: CGRect(x: -barWidth / 2, y: 0, width: barWidth, height: barHeight),
cornerRadius: 6)
bg.position = position
bg.fillColor = SKColor(white: 0.3, alpha: 1.0)
bg.strokeColor = .clear
bg.zPosition = Constants.ZPosition.ui
addChild(bg)
// Progress fill
let fillWidth = barWidth * progress
if fillWidth > 0 {
let fill = SKShapeNode(rect: CGRect(x: -barWidth / 2, y: 0, width: fillWidth, height: barHeight),
cornerRadius: 6)
fill.position = position
fill.fillColor = progress >= 1.0 ? .green : color
fill.strokeColor = .clear
fill.zPosition = Constants.ZPosition.ui + 0.1
addChild(fill)
}
}
private func setupButtons() {
// Retry button
retryButton = createButton(text: "🔄 Nochmal", color: SKColor(red: 0.2, green: 0.6, blue: 0.2, alpha: 1.0))
retryButton.position = CGPoint(x: frame.midX, y: frame.height * 0.15)
retryButton.name = "retryButton"
addChild(retryButton)
// Menu button
menuButton = createButton(text: "🏠 Menü", color: SKColor(red: 0.3, green: 0.3, blue: 0.5, alpha: 1.0))
menuButton.position = CGPoint(x: frame.midX, y: frame.height * 0.08)
menuButton.name = "menuButton"
addChild(menuButton)
}
private func createButton(text: String, color: SKColor) -> SKShapeNode {
let buttonWidth: CGFloat = 180
let buttonHeight: CGFloat = 50
let button = SKShapeNode(rect: CGRect(x: -buttonWidth / 2, y: -buttonHeight / 2,
width: buttonWidth, height: buttonHeight),
cornerRadius: 12)
button.fillColor = color
button.strokeColor = color.withAlphaComponent(0.5)
button.lineWidth = 2
button.zPosition = Constants.ZPosition.ui
let label = SKLabelNode(text: text)
label.fontName = "AvenirNext-Bold"
label.fontSize = 22
label.fontColor = .white
label.verticalAlignmentMode = .center
label.zPosition = 1
button.addChild(label)
return button
}
private func startAnimations() {
// Fade in effect
alpha = 0
let fadeIn = SKAction.fadeIn(withDuration: 0.5)
run(fadeIn)
// Button pulse
let pulse = SKAction.sequence([
SKAction.scale(to: 1.05, duration: 0.8),
SKAction.scale(to: 1.0, duration: 0.8)
])
retryButton.run(SKAction.repeatForever(pulse))
}
// MARK: - Touch Handling
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let touch = touches.first else { return }
let location = touch.location(in: self)
if retryButton.contains(location) {
retryGame()
} else if menuButton.contains(location) {
returnToMenu()
}
}
private func retryGame() {
let pressDown = SKAction.scale(to: 0.9, duration: 0.1)
let pressUp = SKAction.scale(to: 1.0, duration: 0.1)
retryButton.run(SKAction.sequence([pressDown, pressUp])) { [weak self] in
guard let self = self else { return }
let gameScene = GameScene(size: self.size)
gameScene.scaleMode = self.scaleMode
let transition = SKTransition.fade(withDuration: 0.5)
self.view?.presentScene(gameScene, transition: transition)
}
}
private func returnToMenu() {
let pressDown = SKAction.scale(to: 0.9, duration: 0.1)
let pressUp = SKAction.scale(to: 1.0, duration: 0.1)
menuButton.run(SKAction.sequence([pressDown, pressUp])) { [weak self] in
guard let self = self else { return }
let menuScene = MenuScene(size: self.size)
menuScene.scaleMode = self.scaleMode
let transition = SKTransition.fade(withDuration: 0.5)
self.view?.presentScene(menuScene, transition: transition)
}
}
}
+537
View File
@@ -0,0 +1,537 @@
//
// GameScene.swift
// RollkofferSimulator
//
// Created by Ingo K.
//
import SpriteKit
class GameScene: SKScene {
// MARK: - Game Objects
private var player: PlayerNode!
private var gameState: GameState!
// MARK: - Managers
private var spawnManager: SpawnManager!
private var collisionManager: CollisionManager!
// MARK: - UI Elements
private var livesLabel: SKLabelNode!
private var scoreLabel: SKLabelNode!
private var dogsLabel: SKLabelNode!
private var humansLabel: SKLabelNode!
private var timerLabel: SKLabelNode!
private var pauseButton: SKShapeNode!
private var pauseOverlay: SKNode?
// MARK: - Touch Handling
private var touchOffset: CGPoint = .zero
private var isDragging: Bool = false
// MARK: - Timing
private var lastUpdateTime: TimeInterval = 0
// MARK: - Background Scrolling
private var floorTiles: [SKShapeNode] = []
private let tileSize: CGFloat = 80
// MARK: - Scene Lifecycle
override func didMove(to view: SKView) {
setupGame()
}
// MARK: - Setup
private func setupGame() {
// Initialize game state
gameState = GameState()
gameState.reset()
// Setup physics
physicsWorld.gravity = .zero
collisionManager = CollisionManager()
collisionManager.delegate = self
collisionManager.setScene(self)
physicsWorld.contactDelegate = collisionManager
setupBackground()
setupPlayer()
setupUI()
setupSpawnManager()
spawnManager.startSpawning()
}
private func setupBackground() {
backgroundColor = Constants.Colors.backgroundColor
// Create scrolling floor tiles
let rows = Int(frame.height / tileSize) + 3
let cols = Int(frame.width / tileSize) + 1
for row in 0..<rows {
for col in 0..<cols {
let tile = SKShapeNode(rect: CGRect(x: 0, y: 0,
width: tileSize - 2, height: tileSize - 2))
let isEven = (row + col) % 2 == 0
tile.fillColor = isEven ?
SKColor(white: 0.78, alpha: 1.0) : SKColor(white: 0.72, alpha: 1.0)
tile.strokeColor = SKColor(white: 0.65, alpha: 0.5)
tile.lineWidth = 1
tile.position = CGPoint(x: CGFloat(col) * tileSize,
y: CGFloat(row) * tileSize)
tile.zPosition = Constants.ZPosition.floor
tile.name = "floorTile"
addChild(tile)
floorTiles.append(tile)
}
}
// Add airport markings
addAirportMarkings()
}
private func addAirportMarkings() {
// Center line
let lineWidth: CGFloat = 4
let dashLength: CGFloat = 30
let gapLength: CGFloat = 20
for y in stride(from: CGFloat(0), to: frame.height, by: dashLength + gapLength) {
let dash = SKShapeNode(rect: CGRect(x: frame.midX - lineWidth / 2, y: y,
width: lineWidth, height: dashLength))
dash.fillColor = SKColor.yellow.withAlphaComponent(0.6)
dash.strokeColor = .clear
dash.zPosition = Constants.ZPosition.floor + 0.5
dash.name = "floorMarking"
addChild(dash)
}
}
private func setupPlayer() {
player = PlayerNode()
player.position = CGPoint(x: frame.midX, y: frame.height * 0.15)
addChild(player)
}
private func setupUI() {
let uiY = frame.height - 50
let uiY2 = frame.height - 80
// Lives
livesLabel = createUILabel(text: gameState.formattedLives, fontSize: 24)
livesLabel.position = CGPoint(x: 60, y: uiY)
livesLabel.horizontalAlignmentMode = .left
addChild(livesLabel)
// Score
scoreLabel = createUILabel(text: gameState.formattedScore, fontSize: 20)
scoreLabel.position = CGPoint(x: frame.midX, y: uiY)
addChild(scoreLabel)
// Dogs counter
dogsLabel = createUILabel(text: gameState.formattedDogs, fontSize: 18)
dogsLabel.position = CGPoint(x: frame.width - 120, y: uiY)
addChild(dogsLabel)
// Humans counter
humansLabel = createUILabel(text: gameState.formattedHumans, fontSize: 18)
humansLabel.position = CGPoint(x: frame.width - 50, y: uiY)
addChild(humansLabel)
// Timer
timerLabel = createUILabel(text: "⏱️ \(gameState.formattedTime)", fontSize: 22)
timerLabel.position = CGPoint(x: frame.midX, y: uiY2)
addChild(timerLabel)
// Pause button
setupPauseButton()
// UI background bar
let uiBar = SKShapeNode(rect: CGRect(x: 0, y: frame.height - 100,
width: frame.width, height: 100))
uiBar.fillColor = SKColor.white.withAlphaComponent(0.9)
uiBar.strokeColor = SKColor.gray.withAlphaComponent(0.5)
uiBar.lineWidth = 1
uiBar.zPosition = Constants.ZPosition.ui - 1
addChild(uiBar)
}
private func createUILabel(text: String, fontSize: CGFloat) -> SKLabelNode {
let label = SKLabelNode(text: text)
label.fontName = "AvenirNext-Bold"
label.fontSize = fontSize
label.fontColor = .darkGray
label.zPosition = Constants.ZPosition.ui
label.verticalAlignmentMode = .center
return label
}
private func setupPauseButton() {
let buttonSize: CGFloat = 36
pauseButton = SKShapeNode(rect: CGRect(x: -buttonSize / 2, y: -buttonSize / 2,
width: buttonSize, height: buttonSize),
cornerRadius: 8)
pauseButton.fillColor = SKColor(white: 0.9, alpha: 1.0)
pauseButton.strokeColor = SKColor.gray
pauseButton.lineWidth = 2
pauseButton.position = CGPoint(x: 40, y: frame.height - 75)
pauseButton.zPosition = Constants.ZPosition.ui
pauseButton.name = "pauseButton"
addChild(pauseButton)
// Pause icon (two vertical bars)
let barWidth: CGFloat = 4
let barHeight: CGFloat = 16
let barGap: CGFloat = 5
let leftBar = SKShapeNode(rect: CGRect(x: -barGap - barWidth / 2, y: -barHeight / 2,
width: barWidth, height: barHeight))
leftBar.fillColor = .darkGray
leftBar.strokeColor = .clear
pauseButton.addChild(leftBar)
let rightBar = SKShapeNode(rect: CGRect(x: barGap - barWidth / 2, y: -barHeight / 2,
width: barWidth, height: barHeight))
rightBar.fillColor = .darkGray
rightBar.strokeColor = .clear
pauseButton.addChild(rightBar)
}
private func setupSpawnManager() {
spawnManager = SpawnManager(scene: self)
}
// MARK: - Update Loop
override func update(_ currentTime: TimeInterval) {
guard gameState.currentState == .playing else { return }
// Calculate delta time
let deltaTime = lastUpdateTime > 0 ? currentTime - lastUpdateTime : 0
lastUpdateTime = currentTime
// Update game time
gameState.updateTime(delta: deltaTime)
updateUI()
// Update spawn manager
spawnManager.update(deltaTime: deltaTime)
// Update floor scrolling
updateFloorScrolling(deltaTime: deltaTime)
// Check game end conditions
checkGameEndConditions()
}
private func updateFloorScrolling(deltaTime: TimeInterval) {
let scrollAmount = Constants.scrollSpeed * CGFloat(deltaTime)
for tile in floorTiles {
tile.position.y -= scrollAmount
// Reset tile position when it scrolls off screen
if tile.position.y < -tileSize {
tile.position.y += CGFloat(floorTiles.count / (Int(frame.width / tileSize) + 1)) * tileSize
}
}
// Also scroll floor markings
enumerateChildNodes(withName: "floorMarking") { node, _ in
node.position.y -= scrollAmount
if node.position.y < -30 {
node.position.y += self.frame.height + 50
}
}
}
private func updateUI() {
livesLabel.text = gameState.formattedLives
scoreLabel.text = gameState.formattedScore
dogsLabel.text = gameState.formattedDogs
humansLabel.text = gameState.formattedHumans
timerLabel.text = "⏱️ \(gameState.formattedTime)"
// Flash timer when low
if gameState.timeRemaining <= 10 {
timerLabel.fontColor = .red
if timerLabel.action(forKey: "flash") == nil {
let flash = SKAction.sequence([
SKAction.scale(to: 1.2, duration: 0.25),
SKAction.scale(to: 1.0, duration: 0.25)
])
timerLabel.run(SKAction.repeatForever(flash), withKey: "flash")
}
}
}
private func checkGameEndConditions() {
if gameState.hasWon {
handleVictory()
} else if gameState.hasLost {
handleGameOver()
}
}
// MARK: - Touch Handling
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let touch = touches.first else { return }
let location = touch.location(in: self)
// Check pause button
if pauseButton.contains(location) {
togglePause()
return
}
// Check if touching player for dragging
if player.contains(location) {
isDragging = true
touchOffset = CGPoint(x: player.position.x - location.x,
y: player.position.y - location.y)
} else {
// Move player to touch location
isDragging = true
touchOffset = .zero
player.position = location
player.constrainToScreen(in: frame)
}
}
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
guard isDragging, gameState.currentState == .playing,
let touch = touches.first else { return }
let location = touch.location(in: self)
player.position = CGPoint(x: location.x + touchOffset.x,
y: location.y + touchOffset.y)
player.constrainToScreen(in: frame)
}
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
isDragging = false
}
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
isDragging = false
}
// MARK: - Pause Handling
private func togglePause() {
if gameState.currentState == .playing {
pauseGame()
} else if gameState.currentState == .paused {
resumeGame()
}
}
private func pauseGame() {
gameState.setState(.paused)
spawnManager.stopSpawning()
isPaused = true
showPauseOverlay()
}
private func resumeGame() {
hidePauseOverlay()
gameState.setState(.playing)
spawnManager.startSpawning()
isPaused = false
lastUpdateTime = 0
}
private func showPauseOverlay() {
pauseOverlay = SKNode()
pauseOverlay?.zPosition = Constants.ZPosition.ui + 10
// Dimmed background
let dim = SKShapeNode(rect: frame)
dim.fillColor = SKColor.black.withAlphaComponent(0.6)
dim.strokeColor = .clear
pauseOverlay?.addChild(dim)
// Pause text
let pauseText = SKLabelNode(text: "⏸️ PAUSIERT")
pauseText.fontName = "AvenirNext-Heavy"
pauseText.fontSize = 40
pauseText.fontColor = .white
pauseText.position = CGPoint(x: frame.midX, y: frame.midY + 40)
pauseOverlay?.addChild(pauseText)
// Resume button
let resumeButton = createButton(text: "▶️ Weiter", at: CGPoint(x: frame.midX, y: frame.midY - 20))
resumeButton.name = "resumeButton"
pauseOverlay?.addChild(resumeButton)
// Menu button
let menuButton = createButton(text: "🏠 Menü", at: CGPoint(x: frame.midX, y: frame.midY - 80))
menuButton.name = "menuButton"
pauseOverlay?.addChild(menuButton)
addChild(pauseOverlay!)
}
private func createButton(text: String, at position: CGPoint) -> SKNode {
let container = SKNode()
container.position = position
let bg = SKShapeNode(rect: CGRect(x: -80, y: -25, width: 160, height: 50),
cornerRadius: 10)
bg.fillColor = SKColor(white: 0.2, alpha: 0.9)
bg.strokeColor = .white
bg.lineWidth = 2
container.addChild(bg)
let label = SKLabelNode(text: text)
label.fontName = "AvenirNext-Bold"
label.fontSize = 22
label.fontColor = .white
label.verticalAlignmentMode = .center
container.addChild(label)
return container
}
private func hidePauseOverlay() {
pauseOverlay?.removeFromParent()
pauseOverlay = nil
}
// MARK: - Handle pause overlay touches (override to handle when paused)
func handlePauseOverlayTouch(at location: CGPoint) {
guard let overlay = pauseOverlay else { return }
if let resumeButton = overlay.childNode(withName: "resumeButton"),
resumeButton.contains(location) {
resumeGame()
} else if let menuButton = overlay.childNode(withName: "menuButton"),
menuButton.contains(location) {
returnToMenu()
}
}
// Override touchesBegan to handle pause overlay
func handleTouchInPausedState(_ touches: Set<UITouch>) {
guard let touch = touches.first else { return }
let location = touch.location(in: self)
handlePauseOverlayTouch(at: location)
}
// MARK: - Game End Handling
private func handleDamage() {
guard !gameState.isInvincible else { return }
if gameState.loseLife() {
// Start invincibility
gameState.isInvincible = true
player.startBlinking()
// End invincibility after duration
let wait = SKAction.wait(forDuration: Constants.invincibilityDuration)
run(wait) { [weak self] in
self?.gameState.isInvincible = false
self?.player.stopBlinking()
}
}
}
private func handleVictory() {
gameState.setState(.victory)
spawnManager.stopSpawning()
// Record score
ScoreManager.shared.recordGameEnd(
score: gameState.score,
dogsCollected: gameState.dogsCollected,
humansCollected: gameState.humansCollected,
didWin: true
)
// Transition to victory scene
let victoryScene = VictoryScene(size: size)
victoryScene.scaleMode = scaleMode
victoryScene.finalScore = gameState.score
victoryScene.dogsCollected = gameState.dogsCollected
victoryScene.humansCollected = gameState.humansCollected
victoryScene.timeRemaining = gameState.timeRemaining
let transition = SKTransition.fade(withDuration: 0.5)
view?.presentScene(victoryScene, transition: transition)
}
private func handleGameOver() {
gameState.setState(.gameOver)
spawnManager.stopSpawning()
// Record score
ScoreManager.shared.recordGameEnd(
score: gameState.score,
dogsCollected: gameState.dogsCollected,
humansCollected: gameState.humansCollected,
didWin: false
)
// Transition to game over scene
let gameOverScene = GameOverScene(size: size)
gameOverScene.scaleMode = scaleMode
gameOverScene.finalScore = gameState.score
gameOverScene.dogsCollected = gameState.dogsCollected
gameOverScene.humansCollected = gameState.humansCollected
gameOverScene.isNewHighScore = ScoreManager.shared.isNewHighScore(gameState.score)
let transition = SKTransition.fade(withDuration: 0.5)
view?.presentScene(gameOverScene, transition: transition)
}
private func returnToMenu() {
let menuScene = MenuScene(size: size)
menuScene.scaleMode = scaleMode
let transition = SKTransition.fade(withDuration: 0.5)
view?.presentScene(menuScene, transition: transition)
}
}
// MARK: - CollisionManagerDelegate
extension GameScene: CollisionManagerDelegate {
func didCollectGoodDog(points: Int) {
gameState.addPoints(points)
gameState.collectDog()
// Show collection effect
showPointsEffect(points: points, at: player.position)
}
func didCollectGreenHuman(points: Int) {
gameState.addPoints(points)
gameState.collectHuman()
// Show collection effect
showPointsEffect(points: points, at: player.position)
}
func didHitHarmfulEntity() {
handleDamage()
}
private func showPointsEffect(points: Int, at position: CGPoint) {
let label = SKLabelNode(text: "+\(points)")
label.fontName = "AvenirNext-Bold"
label.fontSize = 24
label.fontColor = .green
label.position = CGPoint(x: position.x, y: position.y + 50)
label.zPosition = Constants.ZPosition.ui
addChild(label)
let moveUp = SKAction.moveBy(x: 0, y: 40, duration: 0.5)
let fadeOut = SKAction.fadeOut(withDuration: 0.5)
let group = SKAction.group([moveUp, fadeOut])
let remove = SKAction.removeFromParent()
label.run(SKAction.sequence([group, remove]))
}
}
+265
View File
@@ -0,0 +1,265 @@
//
// MenuScene.swift
// RollkofferSimulator
//
// Created by Ingo K.
//
import SpriteKit
class MenuScene: SKScene {
// MARK: - Properties
private var startButton: SKShapeNode!
private var titleLabel: SKLabelNode!
private var subtitleLabel: SKLabelNode!
private var highScoreLabel: SKLabelNode!
private var creditsLabel: SKLabelNode!
// MARK: - Decorative elements
private var decorativeSuitcase: PlayerNode!
private var decorativeDogs: [DogNode] = []
private var decorativeHumans: [HumanNode] = []
// MARK: - Scene Lifecycle
override func didMove(to view: SKView) {
setupBackground()
setupTitle()
setupStartButton()
setupHighScore()
setupDecorations()
setupCredits()
startAnimations()
}
// MARK: - Setup
private func setupBackground() {
backgroundColor = Constants.Colors.backgroundColor
// Create floor pattern
let floorHeight = frame.height * 0.6
let floor = SKShapeNode(rect: CGRect(x: 0, y: 0,
width: frame.width, height: floorHeight))
floor.fillColor = Constants.Colors.floorColor
floor.strokeColor = .clear
floor.zPosition = Constants.ZPosition.floor
addChild(floor)
// Add floor tiles pattern
let tileSize: CGFloat = 80
for x in stride(from: CGFloat(0), to: frame.width, by: tileSize) {
for y in stride(from: CGFloat(0), to: floorHeight, by: tileSize) {
let tile = SKShapeNode(rect: CGRect(x: x, y: y,
width: tileSize - 2, height: tileSize - 2))
tile.fillColor = (Int((x + y) / tileSize) % 2 == 0) ?
SKColor(white: 0.78, alpha: 1.0) : SKColor(white: 0.72, alpha: 1.0)
tile.strokeColor = SKColor(white: 0.65, alpha: 0.5)
tile.lineWidth = 1
tile.zPosition = Constants.ZPosition.floor + 0.1
addChild(tile)
}
}
}
private func setupTitle() {
// Main title
titleLabel = SKLabelNode(text: "🧳 Rollkoffer Simulator 🧳")
titleLabel.fontName = "AvenirNext-Heavy"
titleLabel.fontSize = 36
titleLabel.fontColor = SKColor(red: 0.3, green: 0.2, blue: 0.5, alpha: 1.0)
titleLabel.position = CGPoint(x: frame.midX, y: frame.height * 0.85)
titleLabel.zPosition = Constants.ZPosition.ui
addChild(titleLabel)
// Subtitle
subtitleLabel = SKLabelNode(text: "Sammle Hunde & Menschen am Flughafen!")
subtitleLabel.fontName = "AvenirNext-Medium"
subtitleLabel.fontSize = 18
subtitleLabel.fontColor = SKColor.darkGray
subtitleLabel.position = CGPoint(x: frame.midX, y: frame.height * 0.78)
subtitleLabel.zPosition = Constants.ZPosition.ui
addChild(subtitleLabel)
// Goal info
let goalLabel = SKLabelNode(text: "Ziel: 🐕 10 Hunde + 👤 5 Grüne Menschen")
goalLabel.fontName = "AvenirNext-Medium"
goalLabel.fontSize = 16
goalLabel.fontColor = SKColor.gray
goalLabel.position = CGPoint(x: frame.midX, y: frame.height * 0.72)
goalLabel.zPosition = Constants.ZPosition.ui
addChild(goalLabel)
}
private func setupStartButton() {
// Button background
let buttonWidth: CGFloat = 200
let buttonHeight: CGFloat = 60
startButton = SKShapeNode(rect: CGRect(x: -buttonWidth / 2, y: -buttonHeight / 2,
width: buttonWidth, height: buttonHeight),
cornerRadius: 15)
startButton.fillColor = SKColor(red: 0.2, green: 0.7, blue: 0.3, alpha: 1.0)
startButton.strokeColor = SKColor(red: 0.15, green: 0.5, blue: 0.2, alpha: 1.0)
startButton.lineWidth = 3
startButton.position = CGPoint(x: frame.midX, y: frame.height * 0.5)
startButton.zPosition = Constants.ZPosition.ui
startButton.name = "startButton"
addChild(startButton)
// Button label
let buttonLabel = SKLabelNode(text: "▶ START")
buttonLabel.fontName = "AvenirNext-Bold"
buttonLabel.fontSize = 28
buttonLabel.fontColor = .white
buttonLabel.verticalAlignmentMode = .center
buttonLabel.position = .zero
buttonLabel.zPosition = 1
startButton.addChild(buttonLabel)
// Button pulse animation
let scaleUp = SKAction.scale(to: 1.05, duration: 0.8)
let scaleDown = SKAction.scale(to: 1.0, duration: 0.8)
let pulse = SKAction.sequence([scaleUp, scaleDown])
startButton.run(SKAction.repeatForever(pulse))
}
private func setupHighScore() {
let highScore = ScoreManager.shared.highScore
highScoreLabel = SKLabelNode(text: "🏆 High Score: \(highScore)")
highScoreLabel.fontName = "AvenirNext-DemiBold"
highScoreLabel.fontSize = 20
highScoreLabel.fontColor = SKColor(red: 0.8, green: 0.6, blue: 0.1, alpha: 1.0)
highScoreLabel.position = CGPoint(x: frame.midX, y: frame.height * 0.38)
highScoreLabel.zPosition = Constants.ZPosition.ui
addChild(highScoreLabel)
// Statistics label
let victories = ScoreManager.shared.victories
let gamesPlayed = ScoreManager.shared.gamesPlayed
let statsText = "Siege: \(victories) | Spiele: \(gamesPlayed)"
let statsLabel = SKLabelNode(text: statsText)
statsLabel.fontName = "AvenirNext-Regular"
statsLabel.fontSize = 14
statsLabel.fontColor = SKColor.gray
statsLabel.position = CGPoint(x: frame.midX, y: frame.height * 0.33)
statsLabel.zPosition = Constants.ZPosition.ui
addChild(statsLabel)
}
private func setupDecorations() {
// Decorative suitcase
decorativeSuitcase = PlayerNode()
decorativeSuitcase.position = CGPoint(x: frame.midX, y: frame.height * 0.2)
decorativeSuitcase.zPosition = Constants.ZPosition.entities
addChild(decorativeSuitcase)
// Add some decorative dogs
let dogTypes: [DogType] = [.smallGood, .bigGood, .bad]
let dogPositions: [CGPoint] = [
CGPoint(x: frame.width * 0.15, y: frame.height * 0.25),
CGPoint(x: frame.width * 0.85, y: frame.height * 0.22),
CGPoint(x: frame.width * 0.25, y: frame.height * 0.12)
]
for (index, type) in dogTypes.enumerated() {
let dog = DogNode(type: type)
dog.position = dogPositions[index]
dog.zPosition = Constants.ZPosition.entities
addChild(dog)
decorativeDogs.append(dog)
}
// Add decorative humans
let humanTypes: [HumanType] = [.green, .gray]
let humanPositions: [CGPoint] = [
CGPoint(x: frame.width * 0.75, y: frame.height * 0.15),
CGPoint(x: frame.width * 0.6, y: frame.height * 0.1)
]
for (index, type) in humanTypes.enumerated() {
let human = HumanNode(type: type)
human.position = humanPositions[index]
human.zPosition = Constants.ZPosition.entities
addChild(human)
decorativeHumans.append(human)
}
}
private func setupCredits() {
creditsLabel = SKLabelNode(text: "Created by Ingo K.")
creditsLabel.fontName = "AvenirNext-Italic"
creditsLabel.fontSize = 14
creditsLabel.fontColor = SKColor.gray
creditsLabel.position = CGPoint(x: frame.midX, y: 30)
creditsLabel.zPosition = Constants.ZPosition.ui
addChild(creditsLabel)
}
private func startAnimations() {
// Animate decorative suitcase
let moveLeft = SKAction.moveBy(x: -20, y: 0, duration: 1.5)
let moveRight = SKAction.moveBy(x: 40, y: 0, duration: 3.0)
let moveBack = SKAction.moveBy(x: -20, y: 0, duration: 1.5)
let suitcaseSequence = SKAction.sequence([moveLeft, moveRight, moveBack])
decorativeSuitcase.run(SKAction.repeatForever(suitcaseSequence))
// Animate decorative entities
for (index, dog) in decorativeDogs.enumerated() {
let delay = Double(index) * 0.3
let bounce = SKAction.sequence([
SKAction.wait(forDuration: delay),
SKAction.moveBy(x: 0, y: 10, duration: 0.4),
SKAction.moveBy(x: 0, y: -10, duration: 0.4)
])
dog.run(SKAction.repeatForever(bounce))
}
for (index, human) in decorativeHumans.enumerated() {
let delay = Double(index) * 0.4 + 0.2
let sway = SKAction.sequence([
SKAction.wait(forDuration: delay),
SKAction.rotate(byAngle: 0.05, duration: 0.5),
SKAction.rotate(byAngle: -0.1, duration: 1.0),
SKAction.rotate(byAngle: 0.05, duration: 0.5)
])
human.run(SKAction.repeatForever(sway))
}
// Title animation
let titleScale = SKAction.sequence([
SKAction.scale(to: 1.02, duration: 2.0),
SKAction.scale(to: 1.0, duration: 2.0)
])
titleLabel.run(SKAction.repeatForever(titleScale))
}
// MARK: - Touch Handling
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let touch = touches.first else { return }
let location = touch.location(in: self)
if startButton.contains(location) {
startGame()
}
}
private func startGame() {
// Button press effect
let pressDown = SKAction.scale(to: 0.9, duration: 0.1)
let pressUp = SKAction.scale(to: 1.0, duration: 0.1)
startButton.run(SKAction.sequence([pressDown, pressUp])) { [weak self] in
self?.transitionToGame()
}
}
private func transitionToGame() {
let gameScene = GameScene(size: size)
gameScene.scaleMode = scaleMode
let transition = SKTransition.fade(withDuration: 0.5)
view?.presentScene(gameScene, transition: transition)
}
}
@@ -0,0 +1,312 @@
//
// VictoryScene.swift
// RollkofferSimulator
//
// Created by Ingo K.
//
import SpriteKit
class VictoryScene: SKScene {
// MARK: - Properties
var finalScore: Int = 0
var dogsCollected: Int = 0
var humansCollected: Int = 0
var timeRemaining: TimeInterval = 0
private var playAgainButton: SKShapeNode!
private var menuButton: SKShapeNode!
// MARK: - Scene Lifecycle
override func didMove(to view: SKView) {
setupBackground()
setupContent()
setupButtons()
startCelebration()
}
// MARK: - Setup
private func setupBackground() {
backgroundColor = SKColor(red: 0.1, green: 0.2, blue: 0.1, alpha: 1.0)
// Add celebration particles
for _ in 0..<30 {
let confetti = createConfetti()
addChild(confetti)
}
}
private func createConfetti() -> SKShapeNode {
let size = CGFloat.random(in: 8...15)
let confetti = SKShapeNode(rectOf: CGSize(width: size, height: size * 1.5))
confetti.fillColor = [SKColor.red, SKColor.yellow, SKColor.green,
SKColor.blue, SKColor.orange, SKColor.purple].randomElement()!
confetti.strokeColor = .clear
confetti.position = CGPoint(x: CGFloat.random(in: 0...frame.width),
y: frame.height + CGFloat.random(in: 50...200))
confetti.zPosition = Constants.ZPosition.ui - 5
confetti.zRotation = CGFloat.random(in: 0...(.pi * 2))
// Falling animation
let fallDuration = Double.random(in: 3...6)
let fall = SKAction.moveTo(y: -50, duration: fallDuration)
let rotate = SKAction.rotate(byAngle: .pi * 4, duration: fallDuration)
let sway = SKAction.sequence([
SKAction.moveBy(x: 30, y: 0, duration: 0.5),
SKAction.moveBy(x: -30, y: 0, duration: 0.5)
])
let swayRepeat = SKAction.repeat(sway, count: Int(fallDuration))
let group = SKAction.group([fall, rotate, swayRepeat])
let reset = SKAction.run { [weak confetti, weak self] in
confetti?.position = CGPoint(x: CGFloat.random(in: 0...(self?.frame.width ?? 400)),
y: (self?.frame.height ?? 800) + 50)
}
let sequence = SKAction.sequence([group, reset])
confetti.run(SKAction.repeatForever(sequence))
return confetti
}
private func setupContent() {
// Victory title
let titleLabel = SKLabelNode(text: "🎉 GEWONNEN! 🎉")
titleLabel.fontName = "AvenirNext-Heavy"
titleLabel.fontSize = 46
titleLabel.fontColor = .yellow
titleLabel.position = CGPoint(x: frame.midX, y: frame.height * 0.82)
titleLabel.zPosition = Constants.ZPosition.ui
addChild(titleLabel)
// Animate title
let titlePulse = SKAction.sequence([
SKAction.scale(to: 1.1, duration: 0.3),
SKAction.scale(to: 1.0, duration: 0.3)
])
titleLabel.run(SKAction.repeatForever(titlePulse))
// Subtitle
let subtitleLabel = SKLabelNode(text: "Du hast alle Ziele erreicht!")
subtitleLabel.fontName = "AvenirNext-Medium"
subtitleLabel.fontSize = 20
subtitleLabel.fontColor = .white
subtitleLabel.position = CGPoint(x: frame.midX, y: frame.height * 0.74)
subtitleLabel.zPosition = Constants.ZPosition.ui
addChild(subtitleLabel)
// Score display
let scoreLabel = SKLabelNode(text: "Endpunktzahl: \(finalScore)")
scoreLabel.fontName = "AvenirNext-Bold"
scoreLabel.fontSize = 36
scoreLabel.fontColor = SKColor(red: 1.0, green: 0.85, blue: 0.0, alpha: 1.0)
scoreLabel.position = CGPoint(x: frame.midX, y: frame.height * 0.62)
scoreLabel.zPosition = Constants.ZPosition.ui
addChild(scoreLabel)
// Time bonus display
let timeBonus = Int(timeRemaining) * 5
if timeBonus > 0 {
let timeBonusLabel = SKLabelNode(text: "⏱️ Zeitbonus: +\(timeBonus) (\(Int(timeRemaining))s übrig)")
timeBonusLabel.fontName = "AvenirNext-Medium"
timeBonusLabel.fontSize = 18
timeBonusLabel.fontColor = SKColor.cyan
timeBonusLabel.position = CGPoint(x: frame.midX, y: frame.height * 0.55)
timeBonusLabel.zPosition = Constants.ZPosition.ui
addChild(timeBonusLabel)
}
// Stats display
let statsY = frame.height * 0.45
let dogsLabel = SKLabelNode(text: "🐕 Hunde: \(dogsCollected)")
dogsLabel.fontName = "AvenirNext-DemiBold"
dogsLabel.fontSize = 22
dogsLabel.fontColor = .white
dogsLabel.position = CGPoint(x: frame.midX - 80, y: statsY)
dogsLabel.zPosition = Constants.ZPosition.ui
addChild(dogsLabel)
let humansLabel = SKLabelNode(text: "👤 Menschen: \(humansCollected)")
humansLabel.fontName = "AvenirNext-DemiBold"
humansLabel.fontSize = 22
humansLabel.fontColor = .white
humansLabel.position = CGPoint(x: frame.midX + 80, y: statsY)
humansLabel.zPosition = Constants.ZPosition.ui
addChild(humansLabel)
// Happy suitcase with collected items
let happySuitcase = PlayerNode()
happySuitcase.position = CGPoint(x: frame.midX, y: frame.height * 0.28)
addChild(happySuitcase)
// Happy face
let happyFace = SKLabelNode(text: "😄")
happyFace.fontSize = 30
happyFace.position = CGPoint(x: frame.midX, y: frame.height * 0.28 + 20)
happyFace.zPosition = Constants.ZPosition.ui
addChild(happyFace)
// Add small dogs and humans around suitcase
let collectibles = [
("🐕", CGPoint(x: -50, y: 0)),
("🐕", CGPoint(x: 50, y: 0)),
("👤", CGPoint(x: -35, y: 30)),
("👤", CGPoint(x: 35, y: 30))
]
for (emoji, offset) in collectibles {
let label = SKLabelNode(text: emoji)
label.fontSize = 24
label.position = CGPoint(x: frame.midX + offset.x,
y: frame.height * 0.28 + offset.y)
label.zPosition = Constants.ZPosition.ui
addChild(label)
// Bounce animation
let bounce = SKAction.sequence([
SKAction.moveBy(x: 0, y: 5, duration: 0.3),
SKAction.moveBy(x: 0, y: -5, duration: 0.3)
])
label.run(SKAction.repeatForever(bounce))
}
// High score check
if ScoreManager.shared.isNewHighScore(finalScore) {
let highScoreLabel = SKLabelNode(text: "🏆 NEUER HIGHSCORE! 🏆")
highScoreLabel.fontName = "AvenirNext-Heavy"
highScoreLabel.fontSize = 26
highScoreLabel.fontColor = SKColor.yellow
highScoreLabel.position = CGPoint(x: frame.midX, y: frame.height * 0.68)
highScoreLabel.zPosition = Constants.ZPosition.ui
addChild(highScoreLabel)
let glow = SKAction.sequence([
SKAction.fadeAlpha(to: 0.6, duration: 0.4),
SKAction.fadeAlpha(to: 1.0, duration: 0.4)
])
highScoreLabel.run(SKAction.repeatForever(glow))
}
}
private func setupButtons() {
// Play Again button
playAgainButton = createButton(text: "🎮 Nochmal spielen",
color: SKColor(red: 0.2, green: 0.7, blue: 0.3, alpha: 1.0))
playAgainButton.position = CGPoint(x: frame.midX, y: frame.height * 0.13)
playAgainButton.name = "playAgainButton"
addChild(playAgainButton)
// Menu button
menuButton = createButton(text: "🏠 Hauptmenü",
color: SKColor(red: 0.3, green: 0.3, blue: 0.6, alpha: 1.0))
menuButton.position = CGPoint(x: frame.midX, y: frame.height * 0.06)
menuButton.name = "menuButton"
addChild(menuButton)
}
private func createButton(text: String, color: SKColor) -> SKShapeNode {
let buttonWidth: CGFloat = 220
let buttonHeight: CGFloat = 50
let button = SKShapeNode(rect: CGRect(x: -buttonWidth / 2, y: -buttonHeight / 2,
width: buttonWidth, height: buttonHeight),
cornerRadius: 12)
button.fillColor = color
button.strokeColor = .white.withAlphaComponent(0.5)
button.lineWidth = 2
button.zPosition = Constants.ZPosition.ui
let label = SKLabelNode(text: text)
label.fontName = "AvenirNext-Bold"
label.fontSize = 20
label.fontColor = .white
label.verticalAlignmentMode = .center
label.zPosition = 1
button.addChild(label)
return button
}
private func startCelebration() {
// Screen flash
let flash = SKShapeNode(rect: frame)
flash.fillColor = .white
flash.strokeColor = .clear
flash.zPosition = Constants.ZPosition.ui + 100
flash.alpha = 0.8
addChild(flash)
let fadeOut = SKAction.fadeOut(withDuration: 0.5)
let remove = SKAction.removeFromParent()
flash.run(SKAction.sequence([fadeOut, remove]))
// Star burst effect
for _ in 0..<12 {
let star = SKLabelNode(text: "")
star.fontSize = CGFloat.random(in: 20...40)
star.position = CGPoint(x: frame.midX, y: frame.height * 0.82)
star.zPosition = Constants.ZPosition.ui - 1
star.alpha = 0
addChild(star)
let angle = CGFloat.random(in: 0...(.pi * 2))
let distance = CGFloat.random(in: 100...200)
let endPoint = CGPoint(x: frame.midX + cos(angle) * distance,
y: frame.height * 0.82 + sin(angle) * distance)
let fadeIn = SKAction.fadeIn(withDuration: 0.2)
let move = SKAction.move(to: endPoint, duration: 0.5)
let fadeOutStar = SKAction.fadeOut(withDuration: 0.3)
let removeStar = SKAction.removeFromParent()
let group = SKAction.group([move, SKAction.sequence([fadeIn,
SKAction.wait(forDuration: 0.2),
fadeOutStar])])
star.run(SKAction.sequence([SKAction.wait(forDuration: Double.random(in: 0...0.3)),
group, removeStar]))
}
}
// MARK: - Touch Handling
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let touch = touches.first else { return }
let location = touch.location(in: self)
if playAgainButton.contains(location) {
playAgain()
} else if menuButton.contains(location) {
returnToMenu()
}
}
private func playAgain() {
let pressDown = SKAction.scale(to: 0.9, duration: 0.1)
let pressUp = SKAction.scale(to: 1.0, duration: 0.1)
playAgainButton.run(SKAction.sequence([pressDown, pressUp])) { [weak self] in
guard let self = self else { return }
let gameScene = GameScene(size: self.size)
gameScene.scaleMode = self.scaleMode
let transition = SKTransition.fade(withDuration: 0.5)
self.view?.presentScene(gameScene, transition: transition)
}
}
private func returnToMenu() {
let pressDown = SKAction.scale(to: 0.9, duration: 0.1)
let pressUp = SKAction.scale(to: 1.0, duration: 0.1)
menuButton.run(SKAction.sequence([pressDown, pressUp])) { [weak self] in
guard let self = self else { return }
let menuScene = MenuScene(size: self.size)
menuScene.scaleMode = self.scaleMode
let transition = SKTransition.fade(withDuration: 0.5)
self.view?.presentScene(menuScene, transition: transition)
}
}
}
+79
View File
@@ -0,0 +1,79 @@
//
// Constants.swift
// RollkofferSimulator
//
// Created by Ingo K.
//
import SpriteKit
struct Constants {
// MARK: - Game Settings
static let gameTime: TimeInterval = 90.0
static let startLives: Int = 3
static let targetDogs: Int = 10
static let targetHumans: Int = 5
// MARK: - Points
static let pointsSmallGoodDog: Int = 10
static let pointsBigGoodDog: Int = 25
static let pointsGreenHuman: Int = 15
// MARK: - Spawn Settings
static let spawnIntervalMin: TimeInterval = 0.8
static let spawnIntervalMax: TimeInterval = 1.5
static let scrollSpeed: CGFloat = 200.0
// MARK: - Spawn Distribution (cumulative percentages)
static let spawnChanceGoodDog: Int = 40 // 0-39: good dogs
static let spawnChanceBadDog: Int = 60 // 40-59: bad dogs (20%)
static let spawnChanceGreenHuman: Int = 85 // 60-84: green humans (25%)
// 85-99: gray humans (15%)
// MARK: - Sprite Sizes
static let suitcaseSize = CGSize(width: 60, height: 80)
static let smallDogSize = CGSize(width: 40, height: 40)
static let bigDogSize = CGSize(width: 70, height: 70)
static let badDogSize = CGSize(width: 55, height: 55)
static let humanSize = CGSize(width: 50, height: 80)
// MARK: - Physics Categories (Bitmasks)
struct PhysicsCategory {
static let none: UInt32 = 0
static let suitcase: UInt32 = 0x1 << 0
static let goodDog: UInt32 = 0x1 << 1
static let badDog: UInt32 = 0x1 << 2
static let greenHuman: UInt32 = 0x1 << 3
static let grayHuman: UInt32 = 0x1 << 4
static let collectible: UInt32 = goodDog | greenHuman
static let harmful: UInt32 = badDog | grayHuman
static let all: UInt32 = goodDog | badDog | greenHuman | grayHuman
}
// MARK: - Colors
struct Colors {
static let goodDogColor = SKColor(red: 0.85, green: 0.65, blue: 0.13, alpha: 1.0) // Gold brown
static let badDogColor = SKColor.red
static let greenHumanColor = SKColor(red: 0.2, green: 0.8, blue: 0.2, alpha: 1.0)
static let grayHumanColor = SKColor.gray
static let suitcaseColor = SKColor(red: 0.4, green: 0.3, blue: 0.6, alpha: 1.0)
static let backgroundColor = SKColor(red: 0.9, green: 0.9, blue: 0.85, alpha: 1.0)
static let floorColor = SKColor(red: 0.75, green: 0.75, blue: 0.7, alpha: 1.0)
}
// MARK: - Z-Positions
struct ZPosition {
static let background: CGFloat = 0
static let floor: CGFloat = 1
static let entities: CGFloat = 10
static let player: CGFloat = 20
static let ui: CGFloat = 100
}
// MARK: - Animation
static let blinkDuration: TimeInterval = 0.1
static let blinkCount: Int = 6
static let invincibilityDuration: TimeInterval = 1.5
}
+48
View File
@@ -0,0 +1,48 @@
# FamilyAlbums - Apache Configuration
# Security Headers
<IfModule mod_headers.c>
Header set X-Content-Type-Options "nosniff"
Header set X-Frame-Options "SAMEORIGIN"
Header set X-XSS-Protection "1; mode=block"
Header set Referrer-Policy "strict-origin-when-cross-origin"
</IfModule>
# Deny access to config file
<Files "config.php">
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
Order deny,allow
Deny from all
</IfModule>
</Files>
# Deny access to hidden files
<FilesMatch "^\.">
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
Order deny,allow
Deny from all
</IfModule>
</FilesMatch>
# Enable compression
<IfModule mod_deflate.c>
AddOutputFilterByType DEFLATE text/html text/plain text/css application/json application/javascript
</IfModule>
# Cache static assets
<IfModule mod_expires.c>
ExpiresActive On
ExpiresByType image/jpeg "access plus 1 month"
ExpiresByType image/png "access plus 1 month"
ExpiresByType image/gif "access plus 1 month"
ExpiresByType image/webp "access plus 1 month"
</IfModule>
# Default charset
AddDefaultCharset UTF-8
+131
View File
@@ -0,0 +1,131 @@
# FamilyAlbums - Familien-Fotoalbum-Portal
Ein einfaches, PHP-basiertes Portal zur Verwaltung und Anzeige von Familien-Fotoalben mit Links zu Nextcloud.
## Features
- Öffentliche Galerie-Ansicht mit Jahr/Monat-Filter
- Stichwortsuche über Titel, Tags und Beschreibung
- Kommentarfunktion für Familienmitglieder
- Admin-Interface zur Albumverwaltung
- Responsive Design (Tailwind CSS)
- Flat-File Datenbank (JSON) - kein MySQL erforderlich
- Spam-Schutz (Honeypot + Rate-Limiting)
- CSRF-Schutz für Admin-Aktionen
## Installation
### 1. Dateien kopieren
```bash
# Auf den Webserver kopieren
sudo cp -r familyalbums /var/www/
# Berechtigungen setzen
sudo chown -R www-data:www-data /var/www/familyalbums
sudo chmod -R 755 /var/www/familyalbums
sudo chmod 770 /var/www/familyalbums/data
sudo chmod 770 /var/www/familyalbums/thumbnails
```
### 2. Admin-Passwort ändern
**WICHTIG:** Das Standard-Passwort muss vor dem produktiven Einsatz geändert werden!
```bash
# Neuen Passwort-Hash generieren
php -r "echo password_hash('DeinSicheresPasswort', PASSWORD_DEFAULT);"
```
Den generierten Hash in `config.php` eintragen:
```php
define('ADMIN_PASSWORD_HASH', '$2y$10$DEIN_GENERIERTER_HASH_HIER');
```
### 3. Apache Virtual Host (optional)
```apache
<VirtualHost *:80>
ServerName familyalbums.example.com
DocumentRoot /var/www/familyalbums
<Directory /var/www/familyalbums>
AllowOverride All
Require all granted
</Directory>
</VirtualHost>
```
## Verwendung
### Öffentliche Galerie
- URL: `https://deine-domain.ch/`
- Filter nach Jahr und Monat
- Stichwortsuche
- Kommentare zu Alben hinterlassen
### Admin-Bereich
- URL: `https://deine-domain.ch/admin.php`
- Login mit dem konfigurierten Passwort
- Alben hinzufügen, bearbeiten, löschen
- Optional: Vorschaubilder hochladen
- Kommentare moderieren
## Datenstruktur
### albums.json
```json
{
"albums": [
{
"id": "uuid",
"title": "Albumtitel",
"url": "https://nextcloud.../apps/photos/public/...",
"date": "2024-12-25",
"tags": ["tag1", "tag2"],
"description": "Beschreibung",
"thumbnail": "thumbnails/bild.jpg",
"created_at": "2024-12-26T10:00:00+01:00"
}
]
}
```
### comments.json
```json
{
"comments": [
{
"id": "uuid",
"album_id": "album-uuid",
"author": "Name",
"text": "Kommentar",
"created_at": "2024-12-27T14:30:00+01:00"
}
]
}
```
## Sicherheit
- Admin-Passwort mit bcrypt gehasht
- CSRF-Token für alle Admin-Aktionen
- XSS-Schutz durch `htmlspecialchars()`
- Rate-Limiting für Kommentare (5/Minute pro IP)
- Honeypot-Feld gegen Spam-Bots
- `.htaccess` schützt config.php und data/
## Anforderungen
- PHP 8.0+
- Apache mit mod_rewrite (optional)
- Schreibrechte für data/ und thumbnails/
## Lizenz
Privates Projekt für Familien-Nutzung.
+658
View File
@@ -0,0 +1,658 @@
<?php
/**
* FamilyAlbums - Admin Interface
*/
require_once __DIR__ . '/config.php';
session_start();
$pageTitle = SITE_TITLE . ' - Administration';
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= e($pageTitle) ?></title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<style>
.tag-input { display: flex; flex-wrap: wrap; gap: 0.5rem; padding: 0.5rem; border: 1px solid #d1d5db; border-radius: 0.5rem; }
.tag-item { background: #dbeafe; color: #1d4ed8; padding: 0.25rem 0.5rem; border-radius: 9999px; display: flex; align-items: center; gap: 0.25rem; }
.tag-item button { color: #1d4ed8; cursor: pointer; }
.tag-input input { flex: 1; min-width: 100px; border: none; outline: none; }
.suggestions { position: absolute; top: 100%; left: 0; right: 0; background: white; border: 1px solid #d1d5db; border-radius: 0.5rem; max-height: 200px; overflow-y: auto; z-index: 10; }
.suggestions div { padding: 0.5rem 1rem; cursor: pointer; }
.suggestions div:hover { background: #f3f4f6; }
</style>
</head>
<body class="bg-gray-100 min-h-screen">
<!-- Login-Bereich (wird per JS gesteuert) -->
<div id="login-section" class="hidden min-h-screen flex items-center justify-center">
<div class="bg-white p-8 rounded-xl shadow-lg w-full max-w-md">
<h1 class="text-2xl font-bold text-center mb-6">
<i class="fas fa-lock mr-2 text-blue-600"></i>Admin Login
</h1>
<form id="login-form">
<div class="mb-4">
<label class="block text-gray-700 mb-2">Passwort</label>
<input type="password" id="login-password" required
class="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
placeholder="Admin-Passwort eingeben">
</div>
<div id="login-error" class="hidden text-red-500 text-sm mb-4"></div>
<button type="submit" class="w-full bg-blue-600 text-white py-2 rounded-lg hover:bg-blue-700 transition">
<i class="fas fa-sign-in-alt mr-2"></i>Anmelden
</button>
</form>
<p class="mt-4 text-center">
<a href="index.php" class="text-blue-600 hover:underline">
<i class="fas fa-arrow-left mr-1"></i>Zurück zur Galerie
</a>
</p>
</div>
</div>
<!-- Admin-Bereich -->
<div id="admin-section" class="hidden">
<!-- Header -->
<header class="bg-gradient-to-r from-gray-800 to-gray-900 text-white shadow-lg">
<div class="container mx-auto px-4 py-4">
<div class="flex items-center justify-between">
<h1 class="text-xl font-bold">
<i class="fas fa-cog mr-2"></i><?= e($pageTitle) ?>
</h1>
<div class="flex items-center gap-4">
<a href="index.php" class="text-white/80 hover:text-white">
<i class="fas fa-eye mr-1"></i>Galerie
</a>
<button onclick="logout()" class="text-white/80 hover:text-white">
<i class="fas fa-sign-out-alt mr-1"></i>Logout
</button>
</div>
</div>
</div>
</header>
<!-- Tabs -->
<div class="bg-white shadow">
<div class="container mx-auto px-4">
<nav class="flex gap-4">
<button onclick="showTab('albums')" id="tab-albums"
class="tab-btn py-4 px-2 border-b-2 border-blue-600 text-blue-600 font-medium">
<i class="fas fa-images mr-1"></i>Alben
</button>
<button onclick="showTab('comments')" id="tab-comments"
class="tab-btn py-4 px-2 border-b-2 border-transparent text-gray-500 hover:text-gray-700">
<i class="fas fa-comments mr-1"></i>Kommentare
</button>
</nav>
</div>
</div>
<!-- Content -->
<main class="container mx-auto px-4 py-8">
<!-- Alben-Tab -->
<div id="content-albums">
<!-- Album hinzufügen -->
<div class="bg-white rounded-xl shadow-md p-6 mb-8">
<h2 class="text-xl font-semibold mb-4">
<i class="fas fa-plus-circle mr-2 text-green-600"></i>
<span id="form-title">Neues Album hinzufügen</span>
</h2>
<form id="album-form" class="grid grid-cols-1 md:grid-cols-2 gap-4">
<input type="hidden" id="album-id">
<div>
<label class="block text-gray-700 mb-1">Titel *</label>
<input type="text" id="album-title" required
class="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
placeholder="z.B. Weihnachten bei Oma">
</div>
<div>
<label class="block text-gray-700 mb-1">Datum *</label>
<input type="date" id="album-date" required
class="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500">
</div>
<div class="md:col-span-2">
<label class="block text-gray-700 mb-1">Nextcloud-Link *</label>
<input type="url" id="album-url" required
class="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
placeholder="https://nextcloud.example.com/apps/photos/public/...">
</div>
<div class="md:col-span-2">
<label class="block text-gray-700 mb-1">Beschreibung</label>
<textarea id="album-description" rows="2"
class="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
placeholder="Kurze Beschreibung des Albums"></textarea>
</div>
<div class="md:col-span-2 relative">
<label class="block text-gray-700 mb-1">Tags</label>
<div class="tag-input" id="tags-container">
<input type="text" id="tag-input" placeholder="Tag eingeben und Enter drücken">
</div>
<div id="tag-suggestions" class="suggestions hidden"></div>
<input type="hidden" id="album-tags">
</div>
<div class="md:col-span-2">
<label class="block text-gray-700 mb-1">Vorschaubild (optional)</label>
<div class="flex gap-2">
<input type="file" id="thumbnail-file" accept="image/*"
class="flex-1 px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500">
<button type="button" onclick="uploadThumbnail()" class="px-4 py-2 bg-gray-200 rounded-lg hover:bg-gray-300">
<i class="fas fa-upload"></i>
</button>
</div>
<input type="hidden" id="album-thumbnail">
<div id="thumbnail-preview" class="mt-2"></div>
</div>
<div class="md:col-span-2 flex gap-2">
<button type="submit" class="bg-green-600 text-white px-6 py-2 rounded-lg hover:bg-green-700 transition">
<i class="fas fa-save mr-2"></i><span id="submit-text">Speichern</span>
</button>
<button type="button" onclick="resetForm()" class="bg-gray-200 px-6 py-2 rounded-lg hover:bg-gray-300 transition">
<i class="fas fa-times mr-2"></i>Abbrechen
</button>
</div>
</form>
</div>
<!-- Album-Liste -->
<div class="bg-white rounded-xl shadow-md p-6">
<h2 class="text-xl font-semibold mb-4">
<i class="fas fa-list mr-2 text-blue-600"></i>Alle Alben
</h2>
<div id="albums-list" class="overflow-x-auto">
<table class="w-full">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-left text-gray-600">Titel</th>
<th class="px-4 py-3 text-left text-gray-600">Datum</th>
<th class="px-4 py-3 text-left text-gray-600">Tags</th>
<th class="px-4 py-3 text-right text-gray-600">Aktionen</th>
</tr>
</thead>
<tbody id="albums-table-body">
<!-- Wird per JS befüllt -->
</tbody>
</table>
</div>
</div>
</div>
<!-- Kommentare-Tab -->
<div id="content-comments" class="hidden">
<div class="bg-white rounded-xl shadow-md p-6">
<h2 class="text-xl font-semibold mb-4">
<i class="fas fa-comments mr-2 text-blue-600"></i>Alle Kommentare
</h2>
<div id="comments-list" class="space-y-4">
<!-- Wird per JS befüllt -->
</div>
</div>
</div>
</main>
</div>
<!-- Bestätigungs-Modal -->
<div id="confirm-modal" class="hidden fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div class="bg-white rounded-xl shadow-2xl max-w-md w-full p-6">
<h3 class="text-lg font-semibold mb-4" id="confirm-title">Bestätigung</h3>
<p id="confirm-message" class="text-gray-600 mb-6"></p>
<div class="flex justify-end gap-2">
<button onclick="closeConfirm()" class="px-4 py-2 bg-gray-200 rounded-lg hover:bg-gray-300">
Abbrechen
</button>
<button id="confirm-btn" class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700">
Löschen
</button>
</div>
</div>
</div>
<script>
// === State ===
let csrfToken = '';
let allTags = [];
let currentTags = [];
let editingAlbumId = null;
let confirmCallback = null;
// === Auth ===
async function checkAuth() {
const response = await fetch('api.php?action=check_auth');
const data = await response.json();
if (data.authenticated) {
csrfToken = data.csrf;
document.getElementById('login-section').classList.add('hidden');
document.getElementById('admin-section').classList.remove('hidden');
loadAlbums();
loadAllTags();
} else {
document.getElementById('login-section').classList.remove('hidden');
document.getElementById('admin-section').classList.add('hidden');
}
}
document.getElementById('login-form').addEventListener('submit', async (e) => {
e.preventDefault();
const password = document.getElementById('login-password').value;
const errorDiv = document.getElementById('login-error');
try {
const response = await fetch('api.php?action=login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password })
});
const data = await response.json();
if (data.success) {
csrfToken = data.csrf;
document.getElementById('login-section').classList.add('hidden');
document.getElementById('admin-section').classList.remove('hidden');
loadAlbums();
loadAllTags();
} else {
errorDiv.textContent = data.error || 'Login fehlgeschlagen';
errorDiv.classList.remove('hidden');
}
} catch (err) {
errorDiv.textContent = 'Verbindungsfehler';
errorDiv.classList.remove('hidden');
}
});
async function logout() {
await fetch('api.php?action=logout', { method: 'POST' });
location.reload();
}
// === Tabs ===
function showTab(tab) {
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.classList.remove('border-blue-600', 'text-blue-600');
btn.classList.add('border-transparent', 'text-gray-500');
});
document.getElementById(`tab-${tab}`).classList.add('border-blue-600', 'text-blue-600');
document.getElementById(`tab-${tab}`).classList.remove('border-transparent', 'text-gray-500');
document.getElementById('content-albums').classList.add('hidden');
document.getElementById('content-comments').classList.add('hidden');
document.getElementById(`content-${tab}`).classList.remove('hidden');
if (tab === 'comments') {
loadAllComments();
}
}
// === Albums ===
async function loadAlbums() {
const response = await fetch('api.php?action=albums');
const data = await response.json();
renderAlbumsTable(data.albums || []);
}
function renderAlbumsTable(albums) {
const tbody = document.getElementById('albums-table-body');
if (albums.length === 0) {
tbody.innerHTML = '<tr><td colspan="4" class="text-center py-8 text-gray-500">Noch keine Alben vorhanden</td></tr>';
return;
}
tbody.innerHTML = albums.map(album => `
<tr class="border-t hover:bg-gray-50">
<td class="px-4 py-3">
<div class="font-medium">${escapeHtml(album.title)}</div>
<div class="text-sm text-gray-500 truncate max-w-xs">${escapeHtml(album.url)}</div>
</td>
<td class="px-4 py-3 text-gray-600">${album.date}</td>
<td class="px-4 py-3">
<div class="flex flex-wrap gap-1">
${album.tags.slice(0, 3).map(tag =>
`<span class="bg-blue-100 text-blue-700 text-xs px-2 py-0.5 rounded-full">${escapeHtml(tag)}</span>`
).join('')}
${album.tags.length > 3 ? `<span class="text-gray-400 text-xs">+${album.tags.length - 3}</span>` : ''}
</div>
</td>
<td class="px-4 py-3 text-right">
<button onclick='editAlbum(${JSON.stringify(album).replace(/'/g, "&#39;")})' class="text-blue-600 hover:text-blue-800 mr-2">
<i class="fas fa-edit"></i>
</button>
<button onclick="confirmDelete('album', '${album.id}', '${escapeHtml(album.title)}')" class="text-red-600 hover:text-red-800">
<i class="fas fa-trash"></i>
</button>
</td>
</tr>
`).join('');
}
async function loadAllTags() {
const response = await fetch('api.php?action=tags');
const data = await response.json();
allTags = data.tags || [];
}
// === Album Form ===
document.getElementById('album-form').addEventListener('submit', async (e) => {
e.preventDefault();
const album = {
csrf: csrfToken,
title: document.getElementById('album-title').value,
url: document.getElementById('album-url').value,
date: document.getElementById('album-date').value,
description: document.getElementById('album-description').value,
tags: currentTags,
thumbnail: document.getElementById('album-thumbnail').value
};
let url = 'api.php?action=album';
let method = 'POST';
if (editingAlbumId) {
album.id = editingAlbumId;
method = 'PUT';
}
try {
const response = await fetch(url, {
method: method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(album)
});
const data = await response.json();
if (data.success) {
resetForm();
loadAlbums();
loadAllTags();
} else {
alert(data.error || 'Fehler beim Speichern');
}
} catch (err) {
alert('Verbindungsfehler');
}
});
function editAlbum(album) {
editingAlbumId = album.id;
document.getElementById('album-id').value = album.id;
document.getElementById('album-title').value = album.title;
document.getElementById('album-url').value = album.url;
document.getElementById('album-date').value = album.date;
document.getElementById('album-description').value = album.description || '';
document.getElementById('album-thumbnail').value = album.thumbnail || '';
// Tags
currentTags = [...album.tags];
renderTags();
// Thumbnail preview
if (album.thumbnail) {
document.getElementById('thumbnail-preview').innerHTML =
`<img src="${escapeHtml(album.thumbnail)}" class="h-20 rounded">`;
}
document.getElementById('form-title').textContent = 'Album bearbeiten';
document.getElementById('submit-text').textContent = 'Aktualisieren';
// Scroll to form
document.getElementById('album-form').scrollIntoView({ behavior: 'smooth' });
}
function resetForm() {
editingAlbumId = null;
document.getElementById('album-form').reset();
document.getElementById('album-thumbnail').value = '';
document.getElementById('thumbnail-preview').innerHTML = '';
currentTags = [];
renderTags();
document.getElementById('form-title').textContent = 'Neues Album hinzufügen';
document.getElementById('submit-text').textContent = 'Speichern';
}
async function deleteAlbum(id) {
try {
const response = await fetch('api.php?action=album', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, csrf: csrfToken })
});
const data = await response.json();
if (data.success) {
loadAlbums();
} else {
alert(data.error || 'Fehler beim Löschen');
}
} catch (err) {
alert('Verbindungsfehler');
}
}
// === Tags ===
function renderTags() {
const container = document.getElementById('tags-container');
const input = document.getElementById('tag-input');
// Remove existing tag items
container.querySelectorAll('.tag-item').forEach(el => el.remove());
// Add tag items before input
currentTags.forEach((tag, index) => {
const span = document.createElement('span');
span.className = 'tag-item';
span.innerHTML = `${escapeHtml(tag)}<button type="button" onclick="removeTag(${index})">&times;</button>`;
container.insertBefore(span, input);
});
}
function removeTag(index) {
currentTags.splice(index, 1);
renderTags();
}
document.getElementById('tag-input').addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault();
const value = e.target.value.trim();
if (value && !currentTags.includes(value)) {
currentTags.push(value);
renderTags();
}
e.target.value = '';
document.getElementById('tag-suggestions').classList.add('hidden');
}
});
document.getElementById('tag-input').addEventListener('input', (e) => {
const value = e.target.value.toLowerCase();
const suggestions = document.getElementById('tag-suggestions');
if (value.length < 1) {
suggestions.classList.add('hidden');
return;
}
const matches = allTags.filter(tag =>
tag.toLowerCase().includes(value) && !currentTags.includes(tag)
).slice(0, 5);
if (matches.length === 0) {
suggestions.classList.add('hidden');
return;
}
suggestions.innerHTML = matches.map(tag =>
`<div onclick="selectTag('${escapeHtml(tag)}')">${escapeHtml(tag)}</div>`
).join('');
suggestions.classList.remove('hidden');
});
function selectTag(tag) {
if (!currentTags.includes(tag)) {
currentTags.push(tag);
renderTags();
}
document.getElementById('tag-input').value = '';
document.getElementById('tag-suggestions').classList.add('hidden');
}
// === Thumbnail Upload ===
async function uploadThumbnail() {
const fileInput = document.getElementById('thumbnail-file');
if (!fileInput.files[0]) {
alert('Bitte wähle zuerst ein Bild aus');
return;
}
const formData = new FormData();
formData.append('thumbnail', fileInput.files[0]);
formData.append('csrf', csrfToken);
try {
const response = await fetch('api.php?action=upload_thumbnail', {
method: 'POST',
body: formData
});
const data = await response.json();
if (data.success) {
document.getElementById('album-thumbnail').value = data.path;
document.getElementById('thumbnail-preview').innerHTML =
`<img src="${escapeHtml(data.path)}" class="h-20 rounded">`;
fileInput.value = '';
} else {
alert(data.error || 'Upload fehlgeschlagen');
}
} catch (err) {
alert('Verbindungsfehler');
}
}
// === Comments ===
async function loadAllComments() {
const albumsResponse = await fetch('api.php?action=albums');
const albumsData = await albumsResponse.json();
const albums = albumsData.albums || [];
const commentsContainer = document.getElementById('comments-list');
commentsContainer.innerHTML = '<p class="text-center"><i class="fas fa-spinner fa-spin"></i> Lade Kommentare...</p>';
// Kommentare für alle Alben laden
const allComments = [];
for (const album of albums) {
const response = await fetch(`api.php?action=comments&album_id=${album.id}`);
const data = await response.json();
(data.comments || []).forEach(comment => {
comment.albumTitle = album.title;
allComments.push(comment);
});
}
// Nach Datum sortieren
allComments.sort((a, b) => b.created_at.localeCompare(a.created_at));
if (allComments.length === 0) {
commentsContainer.innerHTML = '<p class="text-center text-gray-500 py-8">Noch keine Kommentare vorhanden</p>';
return;
}
commentsContainer.innerHTML = allComments.map(comment => `
<div class="bg-gray-50 p-4 rounded-lg">
<div class="flex justify-between items-start mb-2">
<div>
<span class="font-semibold">${escapeHtml(comment.author)}</span>
<span class="text-gray-400 text-sm ml-2">${formatDateTime(comment.created_at)}</span>
</div>
<button onclick="confirmDelete('comment', '${comment.id}', 'diesen Kommentar')" class="text-red-600 hover:text-red-800">
<i class="fas fa-trash"></i>
</button>
</div>
<p class="text-gray-700 mb-2">${escapeHtml(comment.text)}</p>
<p class="text-sm text-gray-500">
<i class="fas fa-images mr-1"></i>${escapeHtml(comment.albumTitle)}
</p>
</div>
`).join('');
}
async function deleteComment(id) {
try {
const response = await fetch('api.php?action=comment', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, csrf: csrfToken })
});
const data = await response.json();
if (data.success) {
loadAllComments();
} else {
alert(data.error || 'Fehler beim Löschen');
}
} catch (err) {
alert('Verbindungsfehler');
}
}
// === Confirm Modal ===
function confirmDelete(type, id, name) {
document.getElementById('confirm-message').textContent =
`Möchtest du "${name}" wirklich löschen?`;
confirmCallback = () => {
if (type === 'album') {
deleteAlbum(id);
} else if (type === 'comment') {
deleteComment(id);
}
};
document.getElementById('confirm-modal').classList.remove('hidden');
}
function closeConfirm() {
document.getElementById('confirm-modal').classList.add('hidden');
confirmCallback = null;
}
document.getElementById('confirm-btn').addEventListener('click', () => {
if (confirmCallback) {
confirmCallback();
}
closeConfirm();
});
// === Helpers ===
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function formatDateTime(isoStr) {
const date = new Date(isoStr);
return date.toLocaleDateString('de-CH', {
day: 'numeric',
month: 'long',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
// === Init ===
checkAuth();
</script>
</body>
</html>
+350
View File
@@ -0,0 +1,350 @@
<?php
/**
* FamilyAlbums - API Endpunkte
*/
require_once __DIR__ . '/config.php';
session_start();
header('Content-Type: application/json; charset=utf-8');
$method = $_SERVER['REQUEST_METHOD'];
$action = $_GET['action'] ?? '';
// Hilfsfunktion: JSON Response
function json_response(array $data, int $code = 200): void {
http_response_code($code);
echo json_encode($data, JSON_UNESCAPED_UNICODE);
exit;
}
// Hilfsfunktion: Admin-Check
function require_admin(): void {
if (empty($_SESSION['admin_logged_in'])) {
json_response(['error' => 'Nicht autorisiert'], 401);
}
}
// === ALBEN ===
if ($action === 'albums' && $method === 'GET') {
// Alle Alben abrufen (öffentlich)
$data = read_json(ALBUMS_FILE);
$albums = $data['albums'] ?? [];
// Filter: Jahr
if (!empty($_GET['year'])) {
$year = $_GET['year'];
$albums = array_filter($albums, fn($a) => substr($a['date'], 0, 4) === $year);
}
// Filter: Monat
if (!empty($_GET['month'])) {
$month = $_GET['month'];
$albums = array_filter($albums, fn($a) => substr($a['date'], 5, 2) === $month);
}
// Filter: Suche
if (!empty($_GET['search'])) {
$search = mb_strtolower($_GET['search']);
$albums = array_filter($albums, function($a) use ($search) {
$haystack = mb_strtolower($a['title'] . ' ' . $a['description'] . ' ' . implode(' ', $a['tags']));
return str_contains($haystack, $search);
});
}
// Sortierung
$sort = $_GET['sort'] ?? 'newest';
usort($albums, function($a, $b) use ($sort) {
if ($sort === 'oldest') {
return strcmp($a['date'], $b['date']);
}
return strcmp($b['date'], $a['date']); // newest first
});
json_response(['albums' => array_values($albums)]);
}
if ($action === 'album' && $method === 'POST') {
// Album erstellen (Admin)
require_admin();
$input = json_decode(file_get_contents('php://input'), true);
if (!csrf_validate($input['csrf'] ?? '')) {
json_response(['error' => 'Ungültiges CSRF-Token'], 403);
}
if (empty($input['title']) || empty($input['url']) || empty($input['date'])) {
json_response(['error' => 'Titel, URL und Datum sind Pflichtfelder'], 400);
}
$album = [
'id' => generate_uuid(),
'title' => trim($input['title']),
'url' => trim($input['url']),
'date' => $input['date'],
'tags' => array_map('trim', $input['tags'] ?? []),
'description' => trim($input['description'] ?? ''),
'thumbnail' => $input['thumbnail'] ?? '',
'created_at' => date('c')
];
$data = read_json(ALBUMS_FILE);
$data['albums'][] = $album;
write_json(ALBUMS_FILE, $data);
json_response(['success' => true, 'album' => $album]);
}
if ($action === 'album' && $method === 'PUT') {
// Album bearbeiten (Admin)
require_admin();
$input = json_decode(file_get_contents('php://input'), true);
if (!csrf_validate($input['csrf'] ?? '')) {
json_response(['error' => 'Ungültiges CSRF-Token'], 403);
}
$id = $input['id'] ?? '';
$data = read_json(ALBUMS_FILE);
$found = false;
foreach ($data['albums'] as &$album) {
if ($album['id'] === $id) {
$album['title'] = trim($input['title'] ?? $album['title']);
$album['url'] = trim($input['url'] ?? $album['url']);
$album['date'] = $input['date'] ?? $album['date'];
$album['tags'] = array_map('trim', $input['tags'] ?? $album['tags']);
$album['description'] = trim($input['description'] ?? $album['description']);
$album['thumbnail'] = $input['thumbnail'] ?? $album['thumbnail'];
$found = true;
break;
}
}
if (!$found) {
json_response(['error' => 'Album nicht gefunden'], 404);
}
write_json(ALBUMS_FILE, $data);
json_response(['success' => true]);
}
if ($action === 'album' && $method === 'DELETE') {
// Album löschen (Admin)
require_admin();
$input = json_decode(file_get_contents('php://input'), true);
if (!csrf_validate($input['csrf'] ?? '')) {
json_response(['error' => 'Ungültiges CSRF-Token'], 403);
}
$id = $input['id'] ?? '';
$data = read_json(ALBUMS_FILE);
$data['albums'] = array_filter($data['albums'], fn($a) => $a['id'] !== $id);
$data['albums'] = array_values($data['albums']);
write_json(ALBUMS_FILE, $data);
// Zugehörige Kommentare löschen
$comments = read_json(COMMENTS_FILE);
$comments['comments'] = array_filter($comments['comments'], fn($c) => $c['album_id'] !== $id);
$comments['comments'] = array_values($comments['comments']);
write_json(COMMENTS_FILE, $comments);
json_response(['success' => true]);
}
// === KOMMENTARE ===
if ($action === 'comments' && $method === 'GET') {
// Kommentare für Album abrufen (öffentlich)
$album_id = $_GET['album_id'] ?? '';
$data = read_json(COMMENTS_FILE);
$comments = array_filter($data['comments'] ?? [], fn($c) => $c['album_id'] === $album_id);
// Nach Datum sortieren (neueste zuerst)
usort($comments, fn($a, $b) => strcmp($b['created_at'], $a['created_at']));
json_response(['comments' => array_values($comments)]);
}
if ($action === 'comment' && $method === 'POST') {
// Kommentar erstellen (öffentlich)
$input = json_decode(file_get_contents('php://input'), true);
if (empty($input['album_id']) || empty($input['author']) || empty($input['text'])) {
json_response(['error' => 'Album-ID, Name und Text sind Pflichtfelder'], 400);
}
// Honeypot-Check (Spam-Schutz)
if (!empty($input['website'])) {
json_response(['success' => true]); // Fake-Erfolg für Bots
}
// Rate-Limiting: Max 5 Kommentare pro Minute pro IP
$ip = $_SERVER['REMOTE_ADDR'];
$rate_file = DATA_PATH . 'rate_' . md5($ip) . '.json';
$rate_data = read_json($rate_file);
$now = time();
$rate_data['times'] = array_filter($rate_data['times'] ?? [], fn($t) => $t > $now - 60);
if (count($rate_data['times']) >= 5) {
json_response(['error' => 'Zu viele Kommentare. Bitte warte eine Minute.'], 429);
}
$rate_data['times'][] = $now;
write_json($rate_file, $rate_data);
$comment = [
'id' => generate_uuid(),
'album_id' => $input['album_id'],
'author' => trim($input['author']),
'text' => trim($input['text']),
'created_at' => date('c')
];
$data = read_json(COMMENTS_FILE);
$data['comments'][] = $comment;
write_json(COMMENTS_FILE, $data);
json_response(['success' => true, 'comment' => $comment]);
}
if ($action === 'comment' && $method === 'DELETE') {
// Kommentar löschen (Admin)
require_admin();
$input = json_decode(file_get_contents('php://input'), true);
if (!csrf_validate($input['csrf'] ?? '')) {
json_response(['error' => 'Ungültiges CSRF-Token'], 403);
}
$id = $input['id'] ?? '';
$data = read_json(COMMENTS_FILE);
$data['comments'] = array_filter($data['comments'], fn($c) => $c['id'] !== $id);
$data['comments'] = array_values($data['comments']);
write_json(COMMENTS_FILE, $data);
json_response(['success' => true]);
}
// === TAGS ===
if ($action === 'tags' && $method === 'GET') {
// Alle verwendeten Tags abrufen (für Vorschläge)
$data = read_json(ALBUMS_FILE);
$tags = [];
foreach ($data['albums'] ?? [] as $album) {
foreach ($album['tags'] ?? [] as $tag) {
$tags[$tag] = ($tags[$tag] ?? 0) + 1;
}
}
arsort($tags);
json_response(['tags' => array_keys($tags)]);
}
// === JAHRE/MONATE ===
if ($action === 'dates' && $method === 'GET') {
// Verfügbare Jahre und Monate
$data = read_json(ALBUMS_FILE);
$years = [];
foreach ($data['albums'] ?? [] as $album) {
$year = substr($album['date'], 0, 4);
$month = substr($album['date'], 5, 2);
if (!isset($years[$year])) {
$years[$year] = [];
}
if (!in_array($month, $years[$year])) {
$years[$year][] = $month;
}
}
// Sortieren
krsort($years);
foreach ($years as &$months) {
sort($months);
}
json_response(['dates' => $years]);
}
// === AUTH ===
if ($action === 'login' && $method === 'POST') {
$input = json_decode(file_get_contents('php://input'), true);
$password = $input['password'] ?? '';
if (password_verify($password, ADMIN_PASSWORD_HASH)) {
$_SESSION['admin_logged_in'] = true;
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
json_response(['success' => true, 'csrf' => $_SESSION['csrf_token']]);
}
// Verzögerung gegen Brute-Force
sleep(1);
json_response(['error' => 'Falsches Passwort'], 401);
}
if ($action === 'logout' && $method === 'POST') {
session_destroy();
json_response(['success' => true]);
}
if ($action === 'check_auth' && $method === 'GET') {
json_response([
'authenticated' => !empty($_SESSION['admin_logged_in']),
'csrf' => $_SESSION['csrf_token'] ?? ''
]);
}
// === THUMBNAIL UPLOAD ===
if ($action === 'upload_thumbnail' && $method === 'POST') {
require_admin();
if (empty($_POST['csrf']) || !csrf_validate($_POST['csrf'])) {
json_response(['error' => 'Ungültiges CSRF-Token'], 403);
}
if (empty($_FILES['thumbnail']) || $_FILES['thumbnail']['error'] !== UPLOAD_ERR_OK) {
json_response(['error' => 'Kein Bild hochgeladen'], 400);
}
$file = $_FILES['thumbnail'];
$allowed = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
if (!in_array($file['type'], $allowed)) {
json_response(['error' => 'Nur JPG, PNG, GIF und WebP erlaubt'], 400);
}
if ($file['size'] > 5 * 1024 * 1024) {
json_response(['error' => 'Maximale Dateigrösse: 5MB'], 400);
}
$ext = pathinfo($file['name'], PATHINFO_EXTENSION);
$filename = generate_uuid() . '.' . $ext;
$path = THUMBNAIL_PATH . $filename;
if (!move_uploaded_file($file['tmp_name'], $path)) {
json_response(['error' => 'Upload fehlgeschlagen'], 500);
}
json_response(['success' => true, 'path' => THUMBNAIL_URL . $filename]);
}
// Unbekannte Aktion
json_response(['error' => 'Unbekannte Aktion'], 404);
+91
View File
@@ -0,0 +1,91 @@
<?php
/**
* FamilyAlbums - Konfiguration
*
* WICHTIG: Nach erster Installation Passwort ändern!
* Neuen Hash generieren: php -r "echo password_hash('deinPasswort', PASSWORD_DEFAULT);"
*/
// Standard-Passwort: "familie2024" - BITTE ÄNDERN!
define('ADMIN_PASSWORD_HASH', '$2y$10$YxQx8B7GkDqNmPrC4VzKH.qN4tQ8WvX5kF7mZ3hJ9aE1bC2dR6uYO');
define('SITE_TITLE', 'Familien-Fotoalben');
define('DATA_PATH', __DIR__ . '/data/');
define('THUMBNAIL_PATH', __DIR__ . '/thumbnails/');
define('THUMBNAIL_URL', 'thumbnails/');
define('ALBUMS_FILE', DATA_PATH . 'albums.json');
define('COMMENTS_FILE', DATA_PATH . 'comments.json');
// Session-Einstellungen
define('SESSION_LIFETIME', 3600); // 1 Stunde
// Zeitzone
date_default_timezone_set('Europe/Zurich');
/**
* JSON-Datei lesen
*/
function read_json(string $file): array {
if (!file_exists($file)) {
return [];
}
$content = file_get_contents($file);
return json_decode($content, true) ?? [];
}
/**
* JSON-Datei schreiben
*/
function write_json(string $file, array $data): bool {
$dir = dirname($file);
if (!is_dir($dir)) {
mkdir($dir, 0770, true);
}
return file_put_contents($file, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)) !== false;
}
/**
* UUID generieren
*/
function generate_uuid(): string {
return sprintf('%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
mt_rand(0, 0xffff), mt_rand(0, 0xffff),
mt_rand(0, 0xffff),
mt_rand(0, 0x0fff) | 0x4000,
mt_rand(0, 0x3fff) | 0x8000,
mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff)
);
}
/**
* XSS-sichere Ausgabe
*/
function e(string $str): string {
return htmlspecialchars($str, ENT_QUOTES, 'UTF-8');
}
/**
* CSRF-Token generieren
*/
function csrf_token(): string {
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf_token'];
}
/**
* CSRF-Token validieren
*/
function csrf_validate(string $token): bool {
return isset($_SESSION['csrf_token']) && hash_equals($_SESSION['csrf_token'], $token);
}
// Initialisiere Daten-Dateien falls nicht vorhanden
if (!file_exists(ALBUMS_FILE)) {
write_json(ALBUMS_FILE, ['albums' => []]);
}
if (!file_exists(COMMENTS_FILE)) {
write_json(COMMENTS_FILE, ['comments' => []]);
}
+8
View File
@@ -0,0 +1,8 @@
# Deny access to all files in this directory
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
Order deny,allow
Deny from all
</IfModule>
+14
View File
@@ -0,0 +1,14 @@
{
"albums": [
{
"id": "demo-001",
"title": "Weihnachten 2024",
"url": "https://nextcloud.example.com/apps/photos/public/demo",
"date": "2024-12-25",
"tags": ["weihnachten", "familie", "2024"],
"description": "Bescherung und Festessen bei der Familie",
"thumbnail": "",
"created_at": "2024-12-26T10:00:00+01:00"
}
]
}
+3
View File
@@ -0,0 +1,3 @@
{
"comments": []
}
+449
View File
@@ -0,0 +1,449 @@
<?php
/**
* FamilyAlbums - Öffentliche Ansicht
*/
require_once __DIR__ . '/config.php';
$pageTitle = SITE_TITLE;
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= e($pageTitle) ?></title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<style>
.album-card:hover { transform: translateY(-4px); }
.tag { transition: all 0.2s; }
.tag:hover { transform: scale(1.05); }
.modal { transition: opacity 0.3s; }
.modal.hidden { opacity: 0; pointer-events: none; }
.gradient-placeholder {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
</style>
</head>
<body class="bg-gray-100 min-h-screen">
<!-- Header -->
<header class="bg-gradient-to-r from-blue-600 to-purple-600 text-white shadow-lg">
<div class="container mx-auto px-4 py-6">
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<h1 class="text-2xl md:text-3xl font-bold">
<i class="fas fa-images mr-2"></i><?= e($pageTitle) ?>
</h1>
<a href="admin.php" class="text-white/80 hover:text-white text-sm">
<i class="fas fa-lock mr-1"></i>Admin
</a>
</div>
</div>
</header>
<!-- Filter-Bereich -->
<div class="bg-white shadow-md sticky top-0 z-10">
<div class="container mx-auto px-4 py-4">
<div class="flex flex-col md:flex-row gap-4">
<!-- Suche -->
<div class="flex-1">
<div class="relative">
<input type="text" id="search" placeholder="Album suchen..."
class="w-full pl-10 pr-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<i class="fas fa-search absolute left-3 top-3 text-gray-400"></i>
</div>
</div>
<!-- Jahr -->
<select id="filter-year" class="px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500">
<option value="">Alle Jahre</option>
</select>
<!-- Monat -->
<select id="filter-month" class="px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500" disabled>
<option value="">Alle Monate</option>
</select>
<!-- Sortierung -->
<select id="sort" class="px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500">
<option value="newest">Neueste zuerst</option>
<option value="oldest">Älteste zuerst</option>
</select>
</div>
</div>
</div>
<!-- Album-Grid -->
<main class="container mx-auto px-4 py-8">
<div id="albums-container" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
<!-- Alben werden per JS geladen -->
</div>
<div id="no-results" class="hidden text-center py-12 text-gray-500">
<i class="fas fa-search text-4xl mb-4"></i>
<p class="text-xl">Keine Alben gefunden</p>
</div>
<div id="loading" class="text-center py-12">
<i class="fas fa-spinner fa-spin text-4xl text-blue-500"></i>
</div>
</main>
<!-- Album-Detail Modal -->
<div id="album-modal" class="modal hidden fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div class="bg-white rounded-xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<div class="p-6">
<div class="flex justify-between items-start mb-4">
<h2 id="modal-title" class="text-2xl font-bold text-gray-800"></h2>
<button onclick="closeModal()" class="text-gray-400 hover:text-gray-600">
<i class="fas fa-times text-xl"></i>
</button>
</div>
<div id="modal-thumbnail" class="mb-4 rounded-lg overflow-hidden"></div>
<p id="modal-date" class="text-gray-500 mb-2"></p>
<p id="modal-description" class="text-gray-700 mb-4"></p>
<div id="modal-tags" class="flex flex-wrap gap-2 mb-6"></div>
<a id="modal-link" href="#" target="_blank"
class="inline-block bg-blue-600 text-white px-6 py-3 rounded-lg hover:bg-blue-700 transition mb-6">
<i class="fas fa-external-link-alt mr-2"></i>Album öffnen
</a>
<!-- Kommentare -->
<div class="border-t pt-6">
<h3 class="text-lg font-semibold mb-4">
<i class="fas fa-comments mr-2"></i>Kommentare
</h3>
<div id="comments-list" class="space-y-4 mb-6"></div>
<!-- Kommentar-Formular -->
<form id="comment-form" class="bg-gray-50 p-4 rounded-lg">
<input type="hidden" id="comment-album-id">
<!-- Honeypot -->
<input type="text" name="website" id="comment-website" class="hidden" tabindex="-1" autocomplete="off">
<div class="mb-3">
<input type="text" id="comment-author" placeholder="Dein Name" required
class="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500">
</div>
<div class="mb-3">
<textarea id="comment-text" placeholder="Dein Kommentar..." required rows="3"
class="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"></textarea>
</div>
<button type="submit" class="bg-green-600 text-white px-4 py-2 rounded-lg hover:bg-green-700 transition">
<i class="fas fa-paper-plane mr-2"></i>Absenden
</button>
</form>
</div>
</div>
</div>
</div>
<!-- Footer -->
<footer class="bg-gray-800 text-white py-6 mt-12">
<div class="container mx-auto px-4 text-center">
<p>&copy; <?= date('Y') ?> <?= e($pageTitle) ?></p>
</div>
</footer>
<script>
// === State ===
let allDates = {};
let currentAlbumId = null;
let debounceTimer = null;
// === Monatsnamen ===
const monthNames = {
'01': 'Januar', '02': 'Februar', '03': 'März', '04': 'April',
'05': 'Mai', '06': 'Juni', '07': 'Juli', '08': 'August',
'09': 'September', '10': 'Oktober', '11': 'November', '12': 'Dezember'
};
// === Helpers ===
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function formatDate(dateStr) {
const [year, month, day] = dateStr.split('-');
return `${parseInt(day)}. ${monthNames[month]} ${year}`;
}
function formatDateTime(isoStr) {
const date = new Date(isoStr);
return date.toLocaleDateString('de-CH', {
day: 'numeric',
month: 'long',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
// === API Calls ===
async function fetchAlbums() {
const params = new URLSearchParams();
const year = document.getElementById('filter-year').value;
const month = document.getElementById('filter-month').value;
const search = document.getElementById('search').value;
const sort = document.getElementById('sort').value;
if (year) params.append('year', year);
if (month) params.append('month', month);
if (search) params.append('search', search);
params.append('sort', sort);
const response = await fetch(`api.php?action=albums&${params}`);
return response.json();
}
async function fetchDates() {
const response = await fetch('api.php?action=dates');
return response.json();
}
async function fetchComments(albumId) {
const response = await fetch(`api.php?action=comments&album_id=${encodeURIComponent(albumId)}`);
return response.json();
}
async function postComment(albumId, author, text, website) {
const response = await fetch('api.php?action=comment', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ album_id: albumId, author, text, website })
});
return response.json();
}
// === Rendering ===
function renderAlbums(albums) {
const container = document.getElementById('albums-container');
const noResults = document.getElementById('no-results');
const loading = document.getElementById('loading');
loading.classList.add('hidden');
if (albums.length === 0) {
container.innerHTML = '';
noResults.classList.remove('hidden');
return;
}
noResults.classList.add('hidden');
container.innerHTML = albums.map(album => `
<div class="album-card bg-white rounded-xl shadow-md overflow-hidden cursor-pointer transition-all duration-300 hover:shadow-xl"
data-album='${JSON.stringify(album).replace(/'/g, "&#39;")}'
onclick="openModalFromCard(this)">
<div class="aspect-video gradient-placeholder flex items-center justify-center">
${album.thumbnail
? `<img src="${escapeHtml(album.thumbnail)}" alt="${escapeHtml(album.title)}" class="w-full h-full object-cover" onerror="this.parentElement.innerHTML='<i class=\\'fas fa-images text-4xl text-white/50\\'></i>'">`
: `<i class="fas fa-images text-4xl text-white/50"></i>`
}
</div>
<div class="p-4">
<h3 class="font-semibold text-lg text-gray-800 mb-1 line-clamp-2">${escapeHtml(album.title)}</h3>
<p class="text-gray-500 text-sm mb-3">
<i class="fas fa-calendar mr-1"></i>${formatDate(album.date)}
</p>
<div class="flex flex-wrap gap-1">
${album.tags.slice(0, 3).map(tag => `
<span class="tag bg-blue-100 text-blue-700 text-xs px-2 py-1 rounded-full">${escapeHtml(tag)}</span>
`).join('')}
${album.tags.length > 3 ? `<span class="text-gray-400 text-xs">+${album.tags.length - 3}</span>` : ''}
</div>
</div>
</div>
`).join('');
}
function renderDateFilters(dates) {
allDates = dates;
const yearSelect = document.getElementById('filter-year');
yearSelect.innerHTML = '<option value="">Alle Jahre</option>' +
Object.keys(dates).map(year => `<option value="${year}">${year}</option>`).join('');
}
function updateMonthFilter() {
const year = document.getElementById('filter-year').value;
const monthSelect = document.getElementById('filter-month');
if (!year || !allDates[year]) {
monthSelect.innerHTML = '<option value="">Alle Monate</option>';
monthSelect.disabled = true;
return;
}
monthSelect.disabled = false;
monthSelect.innerHTML = '<option value="">Alle Monate</option>' +
allDates[year].map(month => `<option value="${month}">${monthNames[month]}</option>`).join('');
}
function renderComments(comments) {
const container = document.getElementById('comments-list');
if (comments.length === 0) {
container.innerHTML = '<p class="text-gray-500 text-center italic">Noch keine Kommentare. Sei der Erste!</p>';
return;
}
container.innerHTML = comments.map(comment => `
<div class="bg-white p-3 rounded-lg border">
<div class="flex justify-between items-start mb-1">
<span class="font-semibold text-gray-800">${escapeHtml(comment.author)}</span>
<span class="text-gray-400 text-xs">${formatDateTime(comment.created_at)}</span>
</div>
<p class="text-gray-700">${escapeHtml(comment.text)}</p>
</div>
`).join('');
}
// === Modal ===
function openModalFromCard(element) {
const album = JSON.parse(element.dataset.album);
openModal(album.id, album);
}
function openModal(id, album) {
currentAlbumId = id;
document.getElementById('modal-title').textContent = album.title;
document.getElementById('modal-date').innerHTML = `<i class="fas fa-calendar mr-1"></i>${formatDate(album.date)}`;
document.getElementById('modal-description').textContent = album.description || 'Keine Beschreibung';
document.getElementById('modal-link').href = album.url;
document.getElementById('comment-album-id').value = id;
// Thumbnail
const thumbnailContainer = document.getElementById('modal-thumbnail');
if (album.thumbnail) {
thumbnailContainer.innerHTML = `<img src="${escapeHtml(album.thumbnail)}" alt="${escapeHtml(album.title)}" class="w-full max-h-64 object-cover">`;
} else {
thumbnailContainer.innerHTML = '';
}
// Tags
document.getElementById('modal-tags').innerHTML = album.tags.map(tag =>
`<span class="bg-blue-100 text-blue-700 text-sm px-3 py-1 rounded-full">${escapeHtml(tag)}</span>`
).join('');
// Modal anzeigen
document.getElementById('album-modal').classList.remove('hidden');
document.body.style.overflow = 'hidden';
// Kommentare laden
loadComments(id);
}
function closeModal() {
document.getElementById('album-modal').classList.add('hidden');
document.body.style.overflow = '';
currentAlbumId = null;
}
async function loadComments(albumId) {
document.getElementById('comments-list').innerHTML = '<p class="text-center"><i class="fas fa-spinner fa-spin"></i></p>';
const data = await fetchComments(albumId);
renderComments(data.comments || []);
}
// === Event Listeners ===
document.getElementById('search').addEventListener('input', () => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(async () => {
const data = await fetchAlbums();
renderAlbums(data.albums || []);
}, 300);
});
document.getElementById('filter-year').addEventListener('change', async () => {
updateMonthFilter();
document.getElementById('filter-month').value = '';
const data = await fetchAlbums();
renderAlbums(data.albums || []);
});
document.getElementById('filter-month').addEventListener('change', async () => {
const data = await fetchAlbums();
renderAlbums(data.albums || []);
});
document.getElementById('sort').addEventListener('change', async () => {
const data = await fetchAlbums();
renderAlbums(data.albums || []);
});
document.getElementById('comment-form').addEventListener('submit', async (e) => {
e.preventDefault();
const albumId = document.getElementById('comment-album-id').value;
const author = document.getElementById('comment-author').value.trim();
const text = document.getElementById('comment-text').value.trim();
const website = document.getElementById('comment-website').value;
if (!author || !text) return;
const btn = e.target.querySelector('button[type="submit"]');
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>Senden...';
try {
const result = await postComment(albumId, author, text, website);
if (result.error) {
alert(result.error);
} else {
document.getElementById('comment-text').value = '';
await loadComments(albumId);
}
} catch (err) {
alert('Fehler beim Senden des Kommentars');
}
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-paper-plane mr-2"></i>Absenden';
});
// Modal schliessen bei Klick ausserhalb
document.getElementById('album-modal').addEventListener('click', (e) => {
if (e.target.id === 'album-modal') {
closeModal();
}
});
// Modal schliessen mit Escape
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
closeModal();
}
});
// === Init ===
async function init() {
try {
const [albumsData, datesData] = await Promise.all([
fetchAlbums(),
fetchDates()
]);
renderAlbums(albumsData.albums || []);
renderDateFilters(datesData.dates || {});
} catch (err) {
console.error('Fehler beim Laden:', err);
document.getElementById('loading').innerHTML =
'<p class="text-red-500"><i class="fas fa-exclamation-triangle mr-2"></i>Fehler beim Laden der Alben</p>';
}
}
init();
</script>
</body>
</html>