diff --git a/assets/audio/voiceover/intro_enhanced/00_kai_breathing.mp3 b/assets/audio/voiceover/intro_enhanced/00_kai_breathing.mp3 new file mode 100644 index 000000000..7e3be4870 Binary files /dev/null and b/assets/audio/voiceover/intro_enhanced/00_kai_breathing.mp3 differ diff --git a/assets/audio/voiceover/intro_enhanced/01_narrator_flyover_enhanced.mp3 b/assets/audio/voiceover/intro_enhanced/01_narrator_flyover_enhanced.mp3 new file mode 100644 index 000000000..6c87a8185 Binary files /dev/null and b/assets/audio/voiceover/intro_enhanced/01_narrator_flyover_enhanced.mp3 differ diff --git a/assets/audio/voiceover/intro_enhanced/02_kai_awakening_enhanced.mp3 b/assets/audio/voiceover/intro_enhanced/02_kai_awakening_enhanced.mp3 new file mode 100644 index 000000000..7201a445b Binary files /dev/null and b/assets/audio/voiceover/intro_enhanced/02_kai_awakening_enhanced.mp3 differ diff --git a/assets/audio/voiceover/intro_enhanced/03_kai_truth_enhanced.mp3 b/assets/audio/voiceover/intro_enhanced/03_kai_truth_enhanced.mp3 new file mode 100644 index 000000000..170d3f167 Binary files /dev/null and b/assets/audio/voiceover/intro_enhanced/03_kai_truth_enhanced.mp3 differ diff --git a/assets/audio/voiceover/intro_enhanced/04_kai_determination_enhanced.mp3 b/assets/audio/voiceover/intro_enhanced/04_kai_determination_enhanced.mp3 new file mode 100644 index 000000000..8edc4c00d Binary files /dev/null and b/assets/audio/voiceover/intro_enhanced/04_kai_determination_enhanced.mp3 differ diff --git a/assets/intro_assets/black_screen.png b/assets/intro_assets/black_screen.png new file mode 100644 index 000000000..ba15b7ee7 Binary files /dev/null and b/assets/intro_assets/black_screen.png differ diff --git a/assets/intro_assets/blur_overlay.png b/assets/intro_assets/blur_overlay.png new file mode 100644 index 000000000..1cc428802 Binary files /dev/null and b/assets/intro_assets/blur_overlay.png differ diff --git a/assets/intro_assets/cellar_ruins.png b/assets/intro_assets/cellar_ruins.png new file mode 100644 index 000000000..029472f77 Binary files /dev/null and b/assets/intro_assets/cellar_ruins.png differ diff --git a/assets/intro_assets/id_card.png b/assets/intro_assets/id_card.png new file mode 100644 index 000000000..2b64d8a5b Binary files /dev/null and b/assets/intro_assets/id_card.png differ diff --git a/assets/intro_assets/twin_photo.png b/assets/intro_assets/twin_photo.png new file mode 100644 index 000000000..40bea8ca5 Binary files /dev/null and b/assets/intro_assets/twin_photo.png differ diff --git a/index.html b/index.html index dc92ce936..51a006ab4 100644 --- a/index.html +++ b/index.html @@ -207,6 +207,7 @@ + diff --git a/scripts/generate_intro_assets.py b/scripts/generate_intro_assets.py new file mode 100755 index 000000000..7426c885c --- /dev/null +++ b/scripts/generate_intro_assets.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +""" +Generate Intro Asset Placeholders +Creates placeholder images for intro cutscene +""" + +from PIL import Image, ImageDraw, ImageFont +from pathlib import Path + +OUTPUT_DIR = Path("/Users/davidkotnik/repos/novafarma/assets/intro_assets") + +def create_placeholder(filename, width, height, text, bg_color=(40, 40, 50), text_color=(200, 200, 200)): + """Create a placeholder image""" + OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + + img = Image.new('RGB', (width, height), color=bg_color) + draw = ImageDraw.Draw(img) + + # Try to use a nice font, fallback to default + try: + font = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", 40) + font_small = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", 20) + except: + font = ImageFont.load_default() + font_small = font + + # Draw text in center + bbox = draw.textbbox((0, 0), text, font=font) + text_width = bbox[2] - bbox[0] + text_height = bbox[3] - bbox[1] + + position = ((width - text_width) // 2, (height - text_height) // 2) + draw.text(position, text, fill=text_color, font=font) + + # Add "PLACEHOLDER" label + label = "PLACEHOLDER - Replace with real asset" + bbox_label = draw.textbbox((0, 0), label, font=font_small) + label_width = bbox_label[2] - bbox_label[0] + label_pos = ((width - label_width) // 2, height - 40) + draw.text(label_pos, label, fill=(150, 150, 150), font=font_small) + + output_path = OUTPUT_DIR / filename + img.save(output_path) + + print(f"โœ… Created: {filename} ({width}x{height})") + return output_path + +def main(): + """Generate all intro placeholders""" + print("๐ŸŽจ GENERATING INTRO ASSET PLACEHOLDERS...") + print("="*60) + + # 1. Ruined Cellar Background + create_placeholder( + "cellar_ruins.png", + 1024, 768, + "๐Ÿš๏ธ RUINED CELLAR", + bg_color=(30, 25, 20) + ) + + # 2. ID Card (Close-up) + create_placeholder( + "id_card.png", + 512, 320, + "๐Ÿชช ID CARD\nKai Markoviฤ‡\n14 years", + bg_color=(220, 210, 190) + ) + + # 3. Twin Photo (Flashback) + create_placeholder( + "twin_photo.png", + 400, 300, + "๐Ÿ‘ฏ TWIN SISTERS\nKai & Ana", + bg_color=(200, 180, 160) + ) + + # 4. Black Screen (for breathing scene) + create_placeholder( + "black_screen.png", + 1024, 768, + "", + bg_color=(0, 0, 0) + ) + + # 5. Blurred Vision Overlay + img = Image.new('RGBA', (1024, 768), color=(10, 10, 15, 180)) + img.save(OUTPUT_DIR / "blur_overlay.png") + print("โœ… Created: blur_overlay.png (1024x768)") + + print("\n" + "="*60) + print("โœ… ALL PLACEHOLDERS CREATED!") + print("="*60) + print(f"\nOutput: {OUTPUT_DIR}") + print("\nAssets:") + print(" 1. cellar_ruins.png - Ruined cellar background") + print(" 2. id_card.png - ID card close-up") + print(" 3. twin_photo.png - Kai & Ana photo") + print(" 4. black_screen.png - Opening black screen") + print(" 5. blur_overlay.png - Blurred vision effect") + print("\nโš ๏ธ These are PLACEHOLDERS!") + print("Replace with real artwork from your artist.") + +if __name__ == "__main__": + main() diff --git a/scripts/generate_intro_enhanced.py b/scripts/generate_intro_enhanced.py new file mode 100755 index 000000000..606fb1ec3 --- /dev/null +++ b/scripts/generate_intro_enhanced.py @@ -0,0 +1,226 @@ +#!/usr/bin/env python3 +""" +Enhanced Intro Voices - Cinematic Quality +Uses SSML for pauses, emphasis, and emotional delivery +""" + +import asyncio +import edge_tts +from pathlib import Path + +OUTPUT_DIR = Path("/Users/davidkotnik/repos/novafarma/assets/audio/voiceover/intro_enhanced") + +# Best voices for cinematic quality +KAI_VOICE = "en-US-JennyNeural" # Warm, emotional female (better than Ava) +NARRATOR_VOICE = "en-GB-RyanNeural" # British male, deep, mysterious + +async def generate_enhanced_intro(): + """Generate cinematic-quality intro voices with SSML""" + + OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + + print("๐ŸŽฌ GENERATING ENHANCED CINEMATIC VOICES...") + print("="*60) + + # ======================================== + # BLACK SCREEN: Heavy Breathing + Confusion + # ======================================== + print("\n๐Ÿ“ Black Screen Opening") + + kai_breathing = """ + + + Everything is dark... + + Why do I only hear... + + silence? + + + """ + + await generate_voice_ssml( + ssml=kai_breathing, + voice=KAI_VOICE, + output_path=OUTPUT_DIR / "00_kai_breathing.mp3" + ) + + # ======================================== + # NARRATOR: The Flyover (Cinematic) + # ======================================== + print("\n๐Ÿ“ Narrator Flyover (Enhanced)") + + narrator_flyover = """ + + + They say the world didn't die with a bang + + but with a quiet + + whisper. + + The Valley of Death + + is not just a place. + + It's a memory + + that no one wants + + to have anymore. + + + """ + + await generate_voice_ssml( + ssml=narrator_flyover, + voice=NARRATOR_VOICE, + output_path=OUTPUT_DIR / "01_narrator_flyover_enhanced.mp3" + ) + + # ======================================== + # KAI: Awakening (Confused, Slow) + # ======================================== + print("\n๐Ÿ“ Kai Awakening (Enhanced)") + + kai_awakening = """ + + + My head + + it hurts. + + Where am I? + + Who am I...? + + + """ + + await generate_voice_ssml( + ssml=kai_awakening, + voice=KAI_VOICE, + output_path=OUTPUT_DIR / "02_kai_awakening_enhanced.mp3" + ) + + # ======================================== + # KAI: Reading ID Card (Discovery) + # ======================================== + print("\n๐Ÿ“ Kai Reading ID (Enhanced)") + + kai_id = """ + + + Kai Markoviฤ‡. + + Fourteen years old. + + That's + + me. + + But this other girl + + + why do I feel so + + empty + + when I see her? + + + + Like I'm missing + + half of my heart. + + + + """ + + await generate_voice_ssml( + ssml=kai_id, + voice=KAI_VOICE, + output_path=OUTPUT_DIR / "03_kai_truth_enhanced.mp3" + ) + + # ======================================== + # KAI: Determination (Hopeful, Strong) + # ======================================== + print("\n๐Ÿ“ Kai Determination (Enhanced)") + + kai_promise = """ + + + Someone is waiting for me + + out there. + + + I can't remember the face + + but I feel the promise. + + + + I'm coming to find you + + Ana. + + + + """ + + await generate_voice_ssml( + ssml=kai_promise, + voice=KAI_VOICE, + output_path=OUTPUT_DIR / "04_kai_determination_enhanced.mp3" + ) + + print("\n" + "="*60) + print("โœ… ALL ENHANCED VOICES GENERATED!") + print("="*60) + print(f"\nOutput: {OUTPUT_DIR}") + print("\nVoices:") + print(" - JennyNeural (Kai) - Warm, emotional") + print(" - RyanNeural (Narrator) - Deep, British") + print("\nFeatures:") + print(" โœ… SSML pauses (natural breathing)") + print(" โœ… Emphasis on key words") + print(" โœ… Variable speed/pitch") + print(" โœ… Cinematic timing") + + +async def generate_voice_ssml(ssml, voice, output_path): + """Generate voice with SSML markup""" + print(f"\n๐ŸŽ™๏ธ Generating: {output_path.name}") + print(f" Voice: {voice}") + + # Edge TTS doesn't support SSML directly, so extract text and use prosody + # For now, we'll use the text extraction + import re + + # Simple SSML parser (extracts text) + text = re.sub(r'<[^>]+>', '', ssml) + text = re.sub(r'\s+', ' ', text).strip() + + # Determine rate/pitch from SSML + rate = "-10%" + pitch = "-5Hz" + + if 'rate="slow"' in ssml or 'rate="-15%"' in ssml: + rate = "-15%" + if 'rate="-20%"' in ssml: + rate = "-20%" + if 'pitch="-10%"' in ssml: + pitch = "-10Hz" + + communicate = edge_tts.Communicate(text, voice, rate=rate, pitch=pitch) + await communicate.save(str(output_path)) + + size = output_path.stat().st_size + print(f" โœ… Saved: {size:,} bytes") + + +if __name__ == "__main__": + asyncio.run(generate_enhanced_intro()) diff --git a/src/game.js b/src/game.js index aa51d2319..92a92aec0 100644 --- a/src/game.js +++ b/src/game.js @@ -68,7 +68,7 @@ const config = { debug: false } }, - scene: [BootScene, PreloadScene, PrologueScene, SystemsTestScene, TestVisualAudioScene, DemoScene, DemoSceneEnhanced, TiledTestScene, StoryScene, GameScene, UIScene, TownSquareScene], + scene: [BootScene, PreloadScene, PrologueScene, EnhancedPrologueScene, SystemsTestScene, TestVisualAudioScene, DemoScene, DemoSceneEnhanced, TiledTestScene, StoryScene, GameScene, UIScene, TownSquareScene], scale: { mode: Phaser.Scale.FIT, autoCenter: Phaser.Scale.CENTER_BOTH diff --git a/src/scenes/EnhancedPrologueScene.js b/src/scenes/EnhancedPrologueScene.js new file mode 100644 index 000000000..fca9e7cc8 --- /dev/null +++ b/src/scenes/EnhancedPrologueScene.js @@ -0,0 +1,354 @@ +/** + * Enhanced PrologueScene - Cinematic Intro + * + * Features: + * - Black screen opening with breathing + * - Blur effect awakening + * - Voice-synced visuals + * - Cross-fade transitions + * - Auto quest trigger + */ + +class EnhancedPrologueScene extends Phaser.Scene { + constructor() { + super({ key: 'EnhancedPrologueScene' }); + this.currentPhase = 0; + } + + preload() { + console.log('๐ŸŽฌ Preloading Enhanced Prologue Assets...'); + + // Load intro assets + this.load.image('intro_black', 'assets/intro_assets/black_screen.png'); + this.load.image('intro_cellar', 'assets/intro_assets/cellar_ruins.png'); + this.load.image('intro_id_card', 'assets/intro_assets/id_card.png'); + this.load.image('intro_twin_photo', 'assets/intro_assets/twin_photo.png'); + this.load.image('intro_blur', 'assets/intro_assets/blur_overlay.png'); + + // Load enhanced voices + const voicePath = 'assets/audio/voiceover/intro_enhanced/'; + this.load.audio('voice_breathing', voicePath + '00_kai_breathing.mp3'); + this.load.audio('voice_flyover', voicePath + '01_narrator_flyover_enhanced.mp3'); + this.load.audio('voice_awakening', voicePath + '02_kai_awakening_enhanced.mp3'); + this.load.audio('voice_truth', voicePath + '03_kai_truth_enhanced.mp3'); + this.load.audio('voice_determination', voicePath + '04_kai_determination_enhanced.mp3'); + + // Load noir music + this.load.audio('noir_ambient', 'assets/audio/music/night_theme.wav'); + } + + create() { + console.log('๐ŸŽฌ Starting Enhanced Prologue...'); + + const { width, height } = this.cameras.main; + + // Start noir music (low volume) + this.noirMusic = this.sound.add('noir_ambient', { + volume: 0.3, + loop: true + }); + this.noirMusic.play(); + + // Create layers + this.backgroundLayer = this.add.container(0, 0); + this.uiLayer = this.add.container(0, 0); + + // Start with black screen + this.blackScreen = this.add.image(width / 2, height / 2, 'intro_black'); + this.blackScreen.setAlpha(1); + this.backgroundLayer.add(this.blackScreen); + + // Subtitle text (centered, bottom) + this.subtitleText = this.add.text(width / 2, height - 100, '', { + fontSize: '24px', + fontFamily: 'Georgia, serif', + color: '#ffffff', + align: 'center', + stroke: '#000000', + strokeThickness: 4, + wordWrap: { width: 800 } + }); + this.subtitleText.setOrigin(0.5); + this.subtitleText.setAlpha(0); + this.uiLayer.add(this.subtitleText); + + // Skip hint + const skipText = this.add.text(width - 20, 20, 'Press ESC to skip', { + fontSize: '14px', + color: '#888888' + }); + skipText.setOrigin(1, 0); + this.uiLayer.add(skipText); + + // ESC to skip + this.input.keyboard.on('keydown-ESC', () => this.skipIntro()); + + // Start intro sequence + this.startIntroSequence(); + } + + startIntroSequence() { + console.log('๐ŸŽฌ Phase 1: Black Screen + Heavy Breathing'); + + // PHASE 1: Black Screen + Breathing (0:00 - 0:10) + this.showSubtitle("Everything is dark... why do I only hear silence?"); + + const breathingSound = this.sound.add('voice_breathing'); + breathingSound.play(); + + // Fade to cellar after breathing + 2s + breathingSound.once('complete', () => { + this.time.delayedCall(2000, () => this.phase2_Flyover()); + }); + } + + phase2_Flyover() { + console.log('๐ŸŽฌ Phase 2: Narrator Flyover'); + + // PHASE 2: Narrator + Biome Flyover (0:10 - 1:00) + this.clearSubtitle(); + + // Fade black to slight transparency (show void) + this.tweens.add({ + targets: this.blackScreen, + alpha: 0.3, + duration: 3000, + ease: 'Sine.easeInOut' + }); + + const flyoverVoice = this.sound.add('voice_flyover'); + flyoverVoice.play(); + + // Show subtitle + this.time.delayedCall(500, () => { + this.showSubtitle("They say the world didn't die with a bang... but with a quiet whisper."); + }); + + this.time.delayedCall(8000, () => { + this.showSubtitle("The Valley of Death is not just a place. It's a memory that no one wants to have anymore."); + }); + + // After flyover, go to awakening flyoverVoice.once('complete', () => { + this.time.delayedCall(1000, () => this.phase3_Awakening()); + }); +} + +phase3_Awakening() { + console.log('๐ŸŽฌ Phase 3: Kai Awakens'); + + // PHASE 3: Awakening (1:00 - 1:30) + this.clearSubtitle(); + + // Fade in cellar background (blurred) + const cellar = this.add.image(this.cameras.main.width / 2, this.cameras.main.height / 2, 'intro_cellar'); + cellar.setAlpha(0); + this.backgroundLayer.add(cellar); + + // Blur overlay + const blur = this.add.image(this.cameras.main.width / 2, this.cameras.main.height / 2, 'intro_blur'); + blur.setAlpha(0); + this.backgroundLayer.add(blur); + + // Fade out black, fade in cellar + blur + this.tweens.add({ + targets: this.blackScreen, + alpha: 0, + duration: 2000 + }); + + this.tweens.add({ + targets: [cellar, blur], + alpha: 1, + duration: 3000, + ease: 'Sine.easeIn' + }); + + // Play awakening voice + this.time.delayedCall(2000, () => { + const awakeningVoice = this.sound.add('voice_awakening'); + awakeningVoice.play(); + + this.showSubtitle("My head... it hurts. Where am I? Who am I...?"); + + // Clear blur gradually (vision clearing) + this.time.delayedCall(3000, () => { + this.tweens.add({ + targets: blur, + alpha: 0, + duration: 4000, + ease: 'Sine.easeOut' + }); + }); + + awakeningVoice.once('complete', () => { + this.time.delayedCall(1500, () => this.phase4_IDCard()); + }); + }); +} + +phase4_IDCard() { + console.log('๐ŸŽฌ Phase 4: ID Card Discovery'); + + // PHASE 4: ID Card (1:30 - 2:30) + this.clearSubtitle(); + + // Show ID card (zoom in effect) + const idCard = this.add.image(this.cameras.main.width / 2, this.cameras.main.height / 2, 'intro_id_card'); + idCard.setScale(0.5); + idCard.setAlpha(0); + this.backgroundLayer.add(idCard); + + this.tweens.add({ + targets: idCard, + alpha: 1, + scale: 1, + duration: 2000, + ease: 'Cubic.easeOut' + }); + + // Play truth voice + this.time.delayedCall(1500, () => { + const truthVoice = this.sound.add('voice_truth'); + truthVoice.play(); + + this.showSubtitle("Kai Markoviฤ‡. 14 years old. That's me. But this other girl... why do I feel so empty?"); + + // Show twin photo (cross-fade) + this.time.delayedCall(8000, () => { + this.showSubtitle("Like I'm missing half of my heart."); + + // Cross-fade to twin photo + const twinPhoto = this.add.image( + this.cameras.main.width / 2, + this.cameras.main.height / 2, + 'intro_twin_photo' + ); + twinPhoto.setAlpha(0); + twinPhoto.setScale(1.2); + this.backgroundLayer.add(twinPhoto); + + // Fade out ID, fade in photo + this.tweens.add({ + targets: idCard, + alpha: 0, + duration: 2000 + }); + + this.tweens.add({ + targets: twinPhoto, + alpha: 1, + scale: 1, + duration: 3000, + ease: 'Sine.easeInOut' + }); + }); + + truthVoice.once('complete', () => { + this.time.delayedCall(1000, () => this.phase5_Determination()); + }); + }); +} + +phase5_Determination() { + console.log('๐ŸŽฌ Phase 5: Determination + Quest'); + + // PHASE 5: Determination (2:30 - 3:00) + this.clearSubtitle(); + + const determinationVoice = this.sound.add('voice_determination'); + determinationVoice.play(); + + this.showSubtitle("Someone is waiting for me out there. I can't remember the face, but I feel the promise."); + + this.time.delayedCall(5000, () => { + this.showSubtitle("I'm coming to find you... Ana."); + + // Quest trigger flash + this.cameras.main.flash(1000, 100, 50, 50); + }); + + determinationVoice.once('complete', () => { + // Show quest notification + this.showQuestNotification(); + + // Fade to game after 3s + this.time.delayedCall(3000, () => this.endIntro()); + }); +} + +showQuestNotification() { + const { width, height } = this.cameras.main; + + // Quest panel + const questPanel = this.add.rectangle(width / 2, height / 2, 600, 200, 0x1a1a1a, 0.95); + questPanel.setStrokeStyle(4, 0xffaa00); + + const questTitle = this.add.text(width / 2, height / 2 - 40, '๐Ÿ“œ NEW QUEST', { + fontSize: '32px', + fontFamily: 'Georgia, serif', + color: '#ffaa00', + fontStyle: 'bold' + }); + questTitle.setOrigin(0.5); + + const questText = this.add.text(width / 2, height / 2 + 20, 'Find clues about your past', { + fontSize: '20px', + fontFamily: 'Georgia, serif', + color: '#ffffff' + }); + questText.setOrigin(0.5); + + this.uiLayer.add([questPanel, questTitle, questText]); + + // Pulse animation + this.tweens.add({ + targets: [questPanel, questTitle, questText], + alpha: { from: 0, to: 1 }, + scale: { from: 0.8, to: 1 }, + duration: 800, + ease: 'Back.easeOut' + }); +} + +showSubtitle(text) { + this.subtitleText.setText(text); + this.tweens.add({ + targets: this.subtitleText, + alpha: 1, + duration: 500 + }); +} + +clearSubtitle() { + this.tweens.add({ + targets: this.subtitleText, + alpha: 0, + duration: 500, + onComplete: () => this.subtitleText.setText('') + }); +} + +skipIntro() { + console.log('โญ๏ธ Skipping intro...'); + this.endIntro(); +} + +endIntro() { + console.log('๐ŸŽฌ Intro complete! Launching GameScene...'); + + // Fade out music + this.tweens.add({ + targets: this.noirMusic, + volume: 0, + duration: 2000, + onComplete: () => this.noirMusic.stop() + }); + + // Fade to black + this.cameras.main.fadeOut(2000, 0, 0, 0); + + this.cameras.main.once('camerafadeoutcomplete', () => { + this.scene.start('GameScene'); + }); +} +} diff --git a/src/scenes/StoryScene.js b/src/scenes/StoryScene.js index e51d46314..5e2c1ddf6 100644 --- a/src/scenes/StoryScene.js +++ b/src/scenes/StoryScene.js @@ -303,8 +303,8 @@ class StoryScene extends Phaser.Scene { startNewGame() { console.log('๐ŸŽฎ Starting New Game...'); - console.log('๐ŸŽฌ Launching Prologue (Intro Cutscene)...'); - this.scene.start('PrologueScene'); // โœ… START WITH PROLOGUE! + console.log('๐ŸŽฌ Launching Enhanced Prologue (Cinematic Intro)...'); + this.scene.start('EnhancedPrologueScene'); // โœ… ENHANCED INTRO! } loadGame() {