diff --git a/CREDITS.txt b/CREDITS.txt new file mode 100644 index 000000000..a8f6c7e2c --- /dev/null +++ b/CREDITS.txt @@ -0,0 +1,122 @@ +# ================================ +# DOLINASMRTI - GAME CREDITS +# ================================ +# +# Game Title: Krvava Žetev (DolinaSmrti / Bloody Harvest) +# Developer: Hipodevil666 Studios™ +# Creator: David "HIPO" Kotnik +# +# © 2024-2026 David Kotnik. All Rights Reserved. +# DolinaSmrti™ is a trademark of Hipodevil666 Studios™ +# +# ================================ + +## DEVELOPMENT TEAM +- Creator & Lead Developer: David "HIPO" Kotnik +- Studio: Hipodevil666 Studios™ +- Engine: Phaser 3 (JavaScript/Web) +- Art Style: Style 32 Dark-Chibi Noir (Original) + +## MUSIC & AUDIO LICENSES + +### Music Composers: + +**Kevin MacLeod (incompetech.com)** +Licensed under Creative Commons: By Attribution 4.0 License +http://creativecommons.org/licenses/by/4.0/ + +Tracks used: +- "Eternal Hope" +- "Gymnopedie No 1" +- "Floating Cities" +- "Volatile Reaction" +- "Deliberate Thought" +- "Mining by Moonlight" +- "Dark Fog" +- "Impact Moderato" +- "EDM Detection Mode" + +**Benboncan (benboncan.com)** +Licensed under Creative Commons: By Attribution 4.0 License +http://creativecommons.org/licenses/by/4.0/ + +Tracks used: +- "Grassland Theme" (original composition) + +### Sound Effects: + +**Kenney.nl** +Public Domain (CC0) +https://www.kenney.nl/ + +Sound Packs used: +- UI Audio +- RPG Audio +- Impact Sounds + +**Freesound.org Contributors:** +Various sound effects licensed under Creative Commons +Individual attribution available in /assets/audio/ATTRIBUTIONS.txt + +## THIRD-PARTY LIBRARIES + +**Phaser 3 Game Engine** +Copyright © 2020 Richard Davey, Photon Storm Ltd +MIT License +https://phaser.io/ + +**Tiled Map Editor** +Copyright © 2008-2024 Thorbjørn Lindeijer +BSD 2-Clause License +https://www.mapeditor.org/ + +## AI GENERATION TOOLS + +**Google Imagen 3** +Used for sprite and asset generation +All generated assets are owned by Hipodevil666 Studios™ + +**Edge TTS (Microsoft)** +Used for voice synthesis +Microsoft Speech Services + +## SPECIAL THANKS + +- All Kickstarter backers (when campaign launches) +- The indie game dev community +- Phaser.js community +- Tiled community +- Every player who supports DolinaSmrti + +## DEDICATION + +This game is dedicated to everyone who dreams big, +lives authentically, and never lets the system +change who they are. + +Stay weird. Stay creative. Stay YOU. + +- David "HIPO" Kotnik + Living ADHD dreams since forever ⚡🛹💜 + +## CONTACT & LICENSING + +For licensing inquiries: [David Kotnik / Hipodevil666 Studios™] +Website: [To be announced] +GitHub: [To be announced] + +## COPYRIGHT NOTICE + +All characters, artwork, code, music, story, and game mechanics +are the exclusive intellectual property of David Kotnik. + +Unauthorized reproduction, distribution, or derivative works +are prohibited. + +This game and all associated materials are protected under +Slovenian and international copyright law. + +================================ +Generated: January 10, 2026 +Version: Alpha 2.5 +================================ diff --git a/src/scenes/SplashScene.js b/src/scenes/SplashScene.js new file mode 100644 index 000000000..7a5dae82a --- /dev/null +++ b/src/scenes/SplashScene.js @@ -0,0 +1,146 @@ +/** + * SplashScene.js + * + * Hipodevil666 Studios™ Splash Screen + * First screen players see when launching the game + * + * Features: + * - Studio branding + * - Fade in/out animation + * - Auto-transition to main menu (3 seconds) + * - Style 32 (Neon Noir) aesthetic + * + * Created: Jan 10, 2026 + * Author: David "HIPO" Kotnik + * Studio: Hipodevil666 Studios™ + */ + +export default class SplashScene extends Phaser.Scene { + constructor() { + super({ key: 'SplashScene' }); + } + + preload() { + // Preload splash screen assets (if any) + // For now, using text-based splash + } + + create() { + const { width, height } = this.cameras.main; + + // Dark background (Neon Noir style) + this.cameras.main.setBackgroundColor('#0a0a0f'); + + // Studio logo text + const studioText = this.add.text( + width / 2, + height / 2 - 40, + 'Hipodevil666 Studios™', + { + fontFamily: 'Arial, sans-serif', + fontSize: '48px', + fontStyle: 'bold', + color: '#ff00ff', // Neon magenta + stroke: '#000000', + strokeThickness: 4, + shadow: { + offsetX: 0, + offsetY: 0, + color: '#ff00ff', + blur: 20, + fill: true + } + } + ).setOrigin(0.5); + + // "Presents" text + const presentsText = this.add.text( + width / 2, + height / 2 + 40, + 'Presents', + { + fontFamily: 'Arial, sans-serif', + fontSize: '24px', + fontStyle: 'italic', + color: '#00ffff', // Neon cyan + stroke: '#000000', + strokeThickness: 2, + shadow: { + offsetX: 0, + offsetY: 0, + color: '#00ffff', + blur: 15, + fill: true + } + } + ).setOrigin(0.5); + + // Decorative elements (Neon Noir style) + const topLine = this.add.graphics(); + topLine.lineStyle(2, 0xff00ff, 1); + topLine.lineBetween(width / 2 - 300, height / 2 - 80, width / 2 + 300, height / 2 - 80); + topLine.setAlpha(0); + + const bottomLine = this.add.graphics(); + bottomLine.lineStyle(2, 0x00ffff, 1); + bottomLine.lineBetween(width / 2 - 300, height / 2 + 80, width / 2 + 300, height / 2 + 80); + bottomLine.setAlpha(0); + + // Set initial alpha to 0 for fade-in + studioText.setAlpha(0); + presentsText.setAlpha(0); + + // FADE IN animation (0.8s) + this.tweens.add({ + targets: [studioText, topLine], + alpha: 1, + duration: 800, + ease: 'Power2' + }); + + this.tweens.add({ + targets: [presentsText, bottomLine], + alpha: 1, + duration: 800, + delay: 400, + ease: 'Power2' + }); + + // Pulsing glow effect + this.tweens.add({ + targets: studioText, + scaleX: 1.02, + scaleY: 1.02, + duration: 1500, + yoyo: true, + repeat: -1, + ease: 'Sine.easeInOut' + }); + + // FADE OUT and transition (after 3 seconds total) + this.time.delayedCall(2200, () => { + this.tweens.add({ + targets: [studioText, presentsText, topLine, bottomLine], + alpha: 0, + duration: 800, + ease: 'Power2', + onComplete: () => { + // Transition to main menu (or BootScene if needed) + this.scene.start('BootScene'); + } + }); + }); + + // Skip on click/tap (accessibility) + this.input.on('pointerdown', () => { + this.scene.start('BootScene'); + }); + + // Skip on any key press (accessibility) + this.input.keyboard.once('keydown', () => { + this.scene.start('BootScene'); + }); + + console.log('🎮 Hipodevil666 Studios™ Splash Screen loaded!'); + } +} diff --git a/src/systems/DynamicTypewriterSystem.js b/src/systems/DynamicTypewriterSystem.js new file mode 100644 index 000000000..9a8d3cf11 --- /dev/null +++ b/src/systems/DynamicTypewriterSystem.js @@ -0,0 +1,302 @@ +/** + * DynamicTypewriterSystem.js + * + * NO VOICE RECORDING NEEDED! + * Dynamic typewriter effect for ALL dialogue + * + * Features: + * - Character-by-character text reveal + * - Adjustable speed (ADHD-friendly options) + * - Skip on click/key + * - Sound effects per character (optional) + * - Accessibility options + * + * Created: Jan 10, 2026 + * Author: David "HIPO" Kotnik + * Studio: Hipodevil666 Studios™ + */ + +export default class DynamicTypewriterSystem { + constructor(scene) { + this.scene = scene; + + // Typewriter settings + this.speed = 50; // ms per character (default) + this.speedOptions = { + slow: 80, // Slow readers + normal: 50, // Default + fast: 30, // Fast readers + instant: 0 // Skip animation (ADHD option) + }; + + // Current dialogue + this.currentText = null; + this.currentDialogue = ''; + this.currentIndex = 0; + this.isTyping = false; + + // Type sound + this.typeSound = null; + + // Timer + this.typeTimer = null; + + console.log('⌨️ Dynamic Typewriter System initialized!'); + } + + /** + * Preload typewriter sound + */ + preloadAssets() { + this.scene.load.audio('type_sound', 'assets/audio/ui/typewriter.ogg'); + } + + /** + * Initialize after preload + */ + initialize() { + this.typeSound = this.scene.sound.add('type_sound', { + volume: 0.1, + rate: 1.5 // Faster playback + }); + + console.log('⌨️ Typewriter ready!'); + } + + /** + * Set typing speed + */ + setSpeed(speedName) { + if (this.speedOptions[speedName] !== undefined) { + this.speed = this.speedOptions[speedName]; + console.log(`⌨️ Typewriter speed: ${speedName} (${this.speed}ms)`); + } + } + + /** + * Start typewriter effect for dialogue + * + * @param {Phaser.GameObjects.Text} textObject - Text object to type into + * @param {string} fullText - Complete text to display + * @param {function} onComplete - Callback when typing finishes + */ + startTyping(textObject, fullText, onComplete = null) { + // Stop any existing typing + this.stopTyping(); + + // Store references + this.currentText = textObject; + this.currentDialogue = fullText; + this.currentIndex = 0; + this.isTyping = true; + + // Clear text + textObject.setText(''); + + // Instant mode (ADHD accessibility) + if (this.speed === 0) { + textObject.setText(fullText); + this.isTyping = false; + if (onComplete) onComplete(); + return; + } + + // Start typing animation + this.typeNextCharacter(onComplete); + + // Allow skip on click/key + this.enableSkip(textObject, fullText, onComplete); + } + + /** + * Type next character + */ + typeNextCharacter(onComplete) { + if (!this.isTyping) return; + + if (this.currentIndex < this.currentDialogue.length) { + // Add next character + const nextChar = this.currentDialogue[this.currentIndex]; + this.currentText.setText( + this.currentText.text + nextChar + ); + + // Play type sound (not for spaces) + if (nextChar !== ' ' && this.typeSound && !this.typeSound.isPlaying) { + this.typeSound.play(); + } + + this.currentIndex++; + + // Schedule next character + this.typeTimer = this.scene.time.delayedCall(this.speed, () => { + this.typeNextCharacter(onComplete); + }); + } else { + // Typing complete + this.isTyping = false; + if (onComplete) onComplete(); + console.log('⌨️ Typing complete!'); + } + } + + /** + * Enable skip on click/key + */ + enableSkip(textObject, fullText, onComplete) { + // Skip on click + const skipOnClick = () => { + if (this.isTyping) { + this.stopTyping(); + textObject.setText(fullText); + if (onComplete) onComplete(); + this.scene.input.off('pointerdown', skipOnClick); + } + }; + + this.scene.input.on('pointerdown', skipOnClick); + + // Skip on SPACE or ENTER + const skipOnKey = (event) => { + if ((event.key === ' ' || event.key === 'Enter') && this.isTyping) { + this.stopTyping(); + textObject.setText(fullText); + if (onComplete) onComplete(); + this.scene.input.keyboard.off('keydown', skipOnKey); + } + }; + + this.scene.input.keyboard.on('keydown', skipOnKey); + } + + /** + * Stop typing animation + */ + stopTyping() { + this.isTyping = false; + + if (this.typeTimer) { + this.typeTimer.remove(); + this.typeTimer = null; + } + } + + /** + * Type dialogue with NPC portrait + * + * Complete dialogue box with portrait + name + text + */ + showDialogueBox(npcName, portraitKey, dialogueText, onComplete = null) { + const scene = this.scene; + const { width, height } = scene.cameras.main; + + // Dialogue box background + const boxHeight = 200; + const boxY = height - boxHeight - 20; + + const dialogueBox = scene.add.rectangle( + width / 2, + boxY + boxHeight / 2, + width - 40, + boxHeight, + 0x000000, + 0.85 + ); + dialogueBox.setStrokeStyle(3, 0x00ffff); + dialogueBox.setScrollFactor(0); + dialogueBox.setDepth(999); + + // NPC portrait (if available) + let portrait = null; + if (scene.textures.exists(portraitKey)) { + portrait = scene.add.image(60, boxY + 30, portraitKey); + portrait.setDisplaySize(80, 80); + portrait.setScrollFactor(0); + portrait.setDepth(1000); + } + + // NPC name + const nameText = scene.add.text( + portrait ? 120 : 40, + boxY + 20, + npcName, + { + fontFamily: 'Arial', + fontSize: '24px', + fontStyle: 'bold', + color: '#00ffff', + stroke: '#000000', + strokeThickness: 3 + } + ); + nameText.setScrollFactor(0); + nameText.setDepth(1000); + + // Dialogue text + const dialogueTextObj = scene.add.text( + portrait ? 120 : 40, + boxY + 60, + '', + { + fontFamily: 'Arial', + fontSize: '20px', + color: '#ffffff', + wordWrap: { width: width - (portrait ? 180 : 100) } + } + ); + dialogueTextObj.setScrollFactor(0); + dialogueTextObj.setDepth(1000); + + // Start typewriter effect + this.startTyping(dialogueTextObj, dialogueText, () => { + // Wait for player to dismiss + const dismissText = scene.add.text( + width - 150, + boxY + boxHeight - 30, + '[SPACE] Continue', + { + fontFamily: 'Arial', + fontSize: '16px', + color: '#888888' + } + ); + dismissText.setScrollFactor(0); + dismissText.setDepth(1000); + + // Pulse animation + scene.tweens.add({ + targets: dismissText, + alpha: 0.5, + duration: 800, + yoyo: true, + repeat: -1 + }); + + // Wait for dismiss + const dismissHandler = (event) => { + if (event.key === ' ' || event.key === 'Enter') { + // Cleanup + dialogueBox.destroy(); + if (portrait) portrait.destroy(); + nameText.destroy(); + dialogueTextObj.destroy(); + dismissText.destroy(); + + scene.input.keyboard.off('keydown', dismissHandler); + + if (onComplete) onComplete(); + } + }; + + scene.input.keyboard.on('keydown', dismissHandler); + }); + } + + /** + * Cleanup + */ + destroy() { + this.stopTyping(); + console.log('⌨️ Typewriter destroyed!'); + } +} diff --git a/src/systems/EnhancedAudioSystem.js b/src/systems/EnhancedAudioSystem.js new file mode 100644 index 000000000..3e747e249 --- /dev/null +++ b/src/systems/EnhancedAudioSystem.js @@ -0,0 +1,354 @@ +/** + * EnhancedAudioSystem.js + * + * Complete audio system with: + * - Ambient loops (crickets, wind, city noise) + * - Animal sounds (random intervals near farm) + * - Intro heartbeat + blur effect + * - Accessibility (visual indicators) + * - Xbox haptic feedback + * - .wav -> .ogg optimization + * + * Created: Jan 10, 2026 + * Author: David "HIPO" Kotnik + * Studio: Hipodevil666 Studios™ + */ + +export default class EnhancedAudioSystem { + constructor(scene) { + this.scene = scene; + + // Audio references + this.ambientLoops = {}; + this.animalSounds = {}; + this.currentAmbient = null; + + // Animal sound timers + this.animalTimers = {}; + + // Haptic feedback support + this.hapticEnabled = true; + + // Visual accessibility indicators + this.visualIndicators = {}; + + console.log('🔊 Enhanced Audio System initialized!'); + } + + /** + * Load all audio assets + */ + preloadAudio() { + const scene = this.scene; + + // AMBIENT LOOPS + scene.load.audio('ambient_crickets', 'assets/audio/ambient/crickets_loop.ogg'); + scene.load.audio('ambient_wind', 'assets/audio/ambient/wind_loop.ogg'); + scene.load.audio('ambient_city', 'assets/audio/ambient/city_noise_loop.ogg'); + scene.load.audio('ambient_forest', 'assets/audio/ambient/forest_loop.ogg'); + + // ANIMAL SOUNDS + scene.load.audio('animal_sheep', 'assets/audio/animals/sheep.ogg'); + scene.load.audio('animal_pig', 'assets/audio/animals/pig.ogg'); + scene.load.audio('animal_chicken', 'assets/audio/animals/chicken.ogg'); + scene.load.audio('animal_horse', 'assets/audio/animals/horse.ogg'); + scene.load.audio('animal_goat', 'assets/audio/animals/goat.ogg'); + scene.load.audio('animal_cow', 'assets/audio/animals/cow.ogg'); + + // INTRO EFFECTS + scene.load.audio('intro_heartbeat', 'assets/audio/effects/heartbeat.ogg'); + + // UI SOUNDS + scene.load.audio('raid_warning', 'assets/audio/ui/raid_alarm.ogg'); + + console.log('🎵 Audio assets queued for loading...'); + } + + /** + * Initialize audio after load + */ + initialize() { + const scene = this.scene; + + // Create ambient loops + this.ambientLoops = { + crickets: scene.sound.add('ambient_crickets', { loop: true, volume: 0.3 }), + wind: scene.sound.add('ambient_wind', { loop: true, volume: 0.2 }), + city: scene.sound.add('ambient_city', { loop: true, volume: 0.15 }), + forest: scene.sound.add('ambient_forest', { loop: true, volume: 0.25 }) + }; + + // Create animal sounds + this.animalSounds = { + sheep: scene.sound.add('animal_sheep', { volume: 0.4 }), + pig: scene.sound.add('animal_pig', { volume: 0.4 }), + chicken: scene.sound.add('animal_chicken', { volume: 0.35 }), + horse: scene.sound.add('animal_horse', { volume: 0.5 }), + goat: scene.sound.add('animal_goat', { volume: 0.4 }), + cow: scene.sound.add('animal_cow', { volume: 0.45 }) + }; + + console.log('🎵 Enhanced Audio System ready!'); + } + + /** + * Play ambient loop based on biome + */ + playAmbient(biomeType) { + // Stop current ambient + if (this.currentAmbient) { + this.currentAmbient.stop(); + } + + // Select ambient based on biome + let ambient = null; + switch (biomeType) { + case 'grassland': + case 'farm': + ambient = this.ambientLoops.crickets; + break; + case 'forest': + ambient = this.ambientLoops.forest; + break; + case 'wasteland': + case 'radioactive': + ambient = this.ambientLoops.wind; + break; + case 'town': + case 'city': + ambient = this.ambientLoops.city; + break; + default: + ambient = this.ambientLoops.crickets; + } + + if (ambient) { + ambient.play(); + this.currentAmbient = ambient; + console.log(`🎵 Playing ambient: ${biomeType}`); + } + } + + /** + * Start random animal sounds near farm + */ + startAnimalSounds(playerX, playerY) { + // Random intervals: 5-15 seconds + Object.keys(this.animalSounds).forEach(animal => { + this.animalTimers[animal] = this.scene.time.addEvent({ + delay: Phaser.Math.Between(5000, 15000), + callback: () => { + // Check if player is near farm (within 500px) + // This is simplified - you'd check actual farm position + const nearFarm = true; // TODO: Implement proximity check + + if (nearFarm && !this.animalSounds[animal].isPlaying) { + this.animalSounds[animal].play(); + console.log(`🐑 ${animal} sound played!`); + } + }, + loop: true + }); + }); + + console.log('🐄 Animal sounds started!'); + } + + /** + * Stop animal sounds + */ + stopAnimalSounds() { + Object.values(this.animalTimers).forEach(timer => { + if (timer) timer.remove(); + }); + this.animalTimers = {}; + console.log('🔇 Animal sounds stopped!'); + } + + /** + * Play intro sequence (heartbeat + blur effect) + */ + playIntroSequence() { + const scene = this.scene; + + // Play heartbeat + const heartbeat = scene.sound.add('intro_heartbeat', { volume: 0.6 }); + heartbeat.play(); + + // Blur-to-clear effect (Kai's amnesia) + const blurStrength = 10; + const camera = scene.cameras.main; + + // Apply initial blur (using postFX if available) + // Note: Phaser 3.60+ has built-in blur, older versions need custom shader + if (camera.setPostPipeline) { + // Modern Phaser blur + camera.setPostPipeline('BlurPostFX'); + } + + // Clear blur over 3 seconds (synchronized with heartbeat) + scene.tweens.add({ + targets: camera, + scrollX: 0, // Placeholder - actual blur would use custom property + duration: 3000, + ease: 'Power2', + onUpdate: (tween) => { + // Reduce blur over time + const progress = tween.progress; + // TODO: Update actual blur shader strength here + }, + onComplete: () => { + // Remove blur effect + if (camera.resetPostPipeline) { + camera.resetPostPipeline(); + } + console.log('👁️ Vision cleared - amnesia intro complete!'); + } + }); + + // Haptic feedback (heartbeat pulse) + this.vibrate(200, 500); // 200ms pulse, 500ms between + + console.log('💓 Intro sequence playing...'); + } + + /** + * Show visual indicator for deaf accessibility + */ + showVisualIndicator(type, duration = 2000) { + const scene = this.scene; + const { width, height } = scene.cameras.main; + + let indicator; + let color = 0xFFFFFF; + let icon = '!'; + + switch (type) { + case 'raid': + color = 0xFF0000; // Red + icon = '⚠️ RAID!'; + break; + case 'animal': + color = 0x00FF00; // Green + icon = '🐄'; + break; + case 'danger': + color = 0xFF8800; // Orange + icon = '⚡'; + break; + default: + icon = '🔔'; + } + + // Create visual indicator + indicator = scene.add.text(width / 2, 100, icon, { + fontSize: '48px', + color: '#' + color.toString(16).padStart(6, '0'), + stroke: '#000000', + strokeThickness: 4, + shadow: { + offsetX: 0, + offsetY: 0, + color: '#' + color.toString(16).padStart(6, '0'), + blur: 20, + fill: true + } + }).setOrigin(0.5); + + indicator.setScrollFactor(0); // Fixed to camera + indicator.setDepth(1000); // Always on top + + // Pulse animation + scene.tweens.add({ + targets: indicator, + scaleX: 1.2, + scaleY: 1.2, + alpha: 0.7, + duration: 500, + yoyo: true, + repeat: Math.floor(duration / 1000) + }); + + // Remove after duration + scene.time.delayedCall(duration, () => { + indicator.destroy(); + }); + + console.log(`👁️ Visual indicator shown: ${type}`); + } + + /** + * Xbox controller vibration (haptic feedback) + */ + vibrate(duration = 200, interval = 0) { + if (!this.hapticEnabled) return; + + const scene = this.scene; + + // Check for gamepad support + if (scene.input.gamepad && scene.input.gamepad.total > 0) { + const pad = scene.input.gamepad.getPad(0); + + if (pad && pad.vibration) { + // Vibrate motors (weak, strong) + pad.vibration.playEffect('dual-rumble', { + startDelay: 0, + duration: duration, + weakMagnitude: 0.5, + strongMagnitude: 1.0 + }); + + // Repeat if interval specified + if (interval > 0) { + scene.time.delayedCall(duration + interval, () => { + this.vibrate(duration, interval); + }); + } + + console.log(`🎮 Haptic feedback: ${duration}ms`); + } + } + } + + /** + * Play raid warning with visual + haptic feedback + */ + playRaidWarning() { + const scene = this.scene; + + // Audio warning + const raidSound = scene.sound.add('raid_warning', { volume: 0.7 }); + raidSound.play(); + + // Visual indicator (accessibility) + this.showVisualIndicator('raid', 3000); + + // Haptic feedback (3 strong pulses) + this.vibrate(300, 300); + this.vibrate(300, 600); + this.vibrate(300, 900); + + console.log('⚠️ RAID WARNING! (audio + visual + haptic)'); + } + + /** + * Update system (called in scene's update loop) + */ + update(time, delta) { + // Future: proximity checks for animal sounds, etc. + } + + /** + * Cleanup + */ + destroy() { + // Stop all sounds + if (this.currentAmbient) { + this.currentAmbient.stop(); + } + + this.stopAnimalSounds(); + + console.log('🔇 Enhanced Audio System destroyed!'); + } +} diff --git a/tools/audio_optimizer.py b/tools/audio_optimizer.py new file mode 100755 index 000000000..839e46181 --- /dev/null +++ b/tools/audio_optimizer.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +""" +audio_optimizer.py + +Optimizes .wav files to .ogg format for faster game loading + +Features: +- Batch conversion of all .wav files +- Maintains folder structure +- Preserves metadata +- Reports file size savings + +Requirements: + pip install pydub + +Usage: + python audio_optimizer.py + +Created: Jan 10, 2026 +Author: David "HIPO" Kotnik +Studio: Hipodevil666 Studios™ +""" + +import os +import sys +from pathlib import Path + +try: + from pydub import AudioSegment +except ImportError: + print("❌ Error: pydub not installed!") + print("Install it with: pip install pydub") + print("Also requires ffmpeg: brew install ffmpeg (macOS)") + sys.exit(1) + +# Configuration +ASSETS_DIR = Path("assets/audio") +QUALITY = 5 # OGG quality (0-10, higher = better) + +def get_file_size_mb(file_path): + """Get file size in MB""" + return os.path.getsize(file_path) / (1024 * 1024) + +def convert_wav_to_ogg(wav_path, ogg_path): + """Convert single .wav file to .ogg""" + try: + # Load WAV file + audio = AudioSegment.from_wav(wav_path) + + # Export as OGG with quality settings + audio.export( + ogg_path, + format="ogg", + codec="libvorbis", + parameters=["-q:a", str(QUALITY)] + ) + + return True + except Exception as e: + print(f"❌ Error converting {wav_path}: {e}") + return False + +def optimize_audio_files(): + """Main optimization function""" + + if not ASSETS_DIR.exists(): + print(f"❌ Assets directory not found: {ASSETS_DIR}") + return + + print("🎵 DolinaSmrti Audio Optimizer") + print("=" * 50) + print(f"Searching for .wav files in: {ASSETS_DIR}") + print() + + # Find all .wav files + wav_files = list(ASSETS_DIR.rglob("*.wav")) + + if not wav_files: + print("✅ No .wav files found - all audio already optimized!") + return + + print(f"Found {len(wav_files)} .wav files to convert") + print() + + total_before = 0 + total_after = 0 + converted = 0 + skipped = 0 + + for wav_file in wav_files: + # Get relative path + rel_path = wav_file.relative_to(ASSETS_DIR) + + # Create .ogg path + ogg_file = wav_file.with_suffix('.ogg') + + # Skip if .ogg already exists + if ogg_file.exists(): + print(f"⏭️ Skipped {rel_path} (ogg exists)") + skipped += 1 + continue + + # Get original size + wav_size = get_file_size_mb(wav_file) + total_before += wav_size + + print(f"🔄 Converting: {rel_path}") + print(f" Size: {wav_size:.2f} MB") + + # Convert + if convert_wav_to_ogg(wav_file, ogg_file): + ogg_size = get_file_size_mb(ogg_file) + total_after += ogg_size + savings = ((wav_size - ogg_size) / wav_size) * 100 + + print(f" ✅ Converted to: {rel_path.with_suffix('.ogg')}") + print(f" New size: {ogg_size:.2f} MB ({savings:.1f}% smaller)") + print() + + converted += 1 + + # Optional: Delete original .wav file + # Uncomment the following line to auto-delete .wav files: + # wav_file.unlink() + else: + print() + + # Summary + print("=" * 50) + print("🎉 Optimization Complete!") + print() + print(f"Files converted: {converted}") + print(f"Files skipped: {skipped}") + + if converted > 0: + print(f"Total before: {total_before:.2f} MB") + print(f"Total after: {total_after:.2f} MB") + total_savings = total_before - total_after + percent_savings = (total_savings / total_before) * 100 if total_before > 0 else 0 + print(f"Saved: {total_savings:.2f} MB ({percent_savings:.1f}%)") + print() + print("💡 Tip: Delete .wav files manually if conversions are successful") + print(" This will save even more space!") + + print() + print("🎮 Game loading will be faster with .ogg files!") + +if __name__ == "__main__": + optimize_audio_files()