/** * TRANS-TEMPORAL EXPRESS — Animation Timeline Reference * * This is a REFERENCE implementation showing the developer how to orchestrate * the visual and audio sequences. It's pseudocode-quality — meant to be * adapted into the existing cockpit.js structure. * * The existing codebase uses: * - A 750ms polling interval reading from #tm-immersive-payload * - Web Audio API for procedural ambient * - CSS class toggling for state transitions * * This reference extends that pattern with the new experience flow. */ // ═══════════════════════════════════════════════════════════════ // ASSET PATHS // ═══════════════════════════════════════════════════════════════ const ASSETS = { images: { cockpit_frame: '/file=static/img/cockpit/cockpit_frame.png', throttle_neutral: '/file=static/img/cockpit/chrono_throttle_neutral.png', comm_screen: '/file=static/img/cockpit/comm_screen.png', gauge_cluster: '/file=static/img/cockpit/gauge_cluster.png', idle_viewport: '/file=static/img/cockpit/idle_viewport.png', travel_future: '/file=static/img/travel/travel_future.png', travel_past: '/file=static/img/travel/travel_past.png', steam_cloud: '/file=static/img/overlays/steam_cloud.png', energy_lines: '/file=static/img/overlays/energy_lines.png', speed_lines: '/file=static/img/overlays/speed_lines.png', // Intro assets temporal_ticket: '/file=static/img/intro/temporal_ticket.png', conductor_box: '/file=static/img/intro/conductor_box.png', train_materialization: '/file=static/img/intro/train_materialization.png', }, audio: { sfx: { ticket_clang: '/file=static/audio/sfx/ticket_clang.mp3', steam_burst: '/file=static/audio/sfx/steam_burst.mp3', brake_screech: '/file=static/audio/sfx/brake_screech.mp3', gears_grinding: '/file=static/audio/sfx/gears_grinding.mp3', train_chug: '/file=static/audio/sfx/train_chug.mp3', train_whistle: '/file=static/audio/sfx/train_whistle.mp3', materialize: '/file=static/audio/sfx/materialize.mp3', time_warp: '/file=static/audio/sfx/time_warp.mp3', charge_up: '/file=static/audio/sfx/charge_up.mp3', shockwave: '/file=static/audio/sfx/shockwave.mp3', chime: '/file=static/audio/sfx/chime.mp3', lever_pull: '/file=static/audio/sfx/lever_pull.mp3', button_click: '/file=static/audio/sfx/button_click.mp3', }, ambient: { temporal_storm: '/file=static/audio/ambient/temporal_storm.mp3', rain: '/file=static/audio/ambient/rain.mp3', marketplace: '/file=static/audio/ambient/marketplace.mp3', fire_hearth: '/file=static/audio/ambient/fire_hearth.mp3', wind: '/file=static/audio/ambient/wind.mp3', machinery: '/file=static/audio/ambient/machinery.mp3', ocean: '/file=static/audio/ambient/ocean.mp3', night_insects: '/file=static/audio/ambient/night_insects.mp3', desert: '/file=static/audio/ambient/desert.mp3', scifi_hum: '/file=static/audio/ambient/scifi_hum.mp3', } } }; // Era sign data — signs that fly past during travel const ERA_SIGNS = { // Past-bound (shown in reverse chronological order from present) past: [ { text: 'THE SPACE AGE: 1960s', year: 1960 }, { text: 'WORLD WARS: 1940s', year: 1940 }, { text: 'THE JAZZ AGE: 1920s', year: 1920 }, { text: 'THE INDUSTRIAL AGE: 1800s', year: 1800 }, { text: 'AGE OF SAIL: 1700s', year: 1700 }, { text: 'THE RENAISSANCE: 1500s', year: 1500 }, { text: 'THE MIDDLE AGES: 1200s', year: 1200 }, { text: 'ANCIENT EMPIRES: 500 BC', year: -500 }, { text: 'DAWN OF CIVILIZATION: 3000 BC', year: -3000 }, ], // Future-bound (shown in forward chronological order from present) future: [ { text: 'NEAR FUTURE: 2050s', year: 2050 }, { text: 'THE SINGULARITY: 2100s', year: 2100 }, { text: 'INTERPLANETARY ERA: 2200s', year: 2200 }, { text: 'THE FEDERATION: 2400s', year: 2400 }, { text: 'POST-SCARCITY: 3000s', year: 3000 }, { text: 'DEEP FUTURE: 5000+', year: 5000 }, ] }; // ═══════════════════════════════════════════════════════════════ // AUDIO MANAGER (extends existing Web Audio setup) // ═══════════════════════════════════════════════════════════════ class AudioManager { constructor() { this.ctx = null; // AudioContext — create on first user interaction this.sfxGain = null; this.ambientGain = null; this.masterGain = null; this.currentAmbient = null; this.preloaded = {}; } init() { if (this.ctx) return; this.ctx = new (window.AudioContext || window.webkitAudioContext)(); this.masterGain = this.ctx.createGain(); this.sfxGain = this.ctx.createGain(); this.ambientGain = this.ctx.createGain(); this.sfxGain.connect(this.masterGain); this.ambientGain.connect(this.masterGain); this.masterGain.connect(this.ctx.destination); this.sfxGain.gain.value = 0.8; this.ambientGain.gain.value = 0.3; } async preload(key, url) { try { const response = await fetch(url); const arrayBuffer = await response.arrayBuffer(); this.preloaded[key] = await this.ctx.decodeAudioData(arrayBuffer); } catch (e) { console.warn(`Failed to preload audio: ${key}`, e); } } playSFX(key) { if (!this.preloaded[key]) return; const source = this.ctx.createBufferSource(); source.buffer = this.preloaded[key]; source.connect(this.sfxGain); source.start(); return source; } playAmbient(key, fadeInMs = 2000) { if (this.currentAmbient) { // Crossfade: fade out current this.currentAmbient.gain.linearRampToValueAtTime( 0, this.ctx.currentTime + fadeInMs / 1000 ); } if (!this.preloaded[key]) return; const source = this.ctx.createBufferSource(); const gain = this.ctx.createGain(); source.buffer = this.preloaded[key]; source.loop = true; source.connect(gain); gain.connect(this.ambientGain); // Fade in gain.gain.setValueAtTime(0, this.ctx.currentTime); gain.gain.linearRampToValueAtTime(1, this.ctx.currentTime + fadeInMs / 1000); source.start(); this.currentAmbient = gain; return source; } // Duck ambient when character speaks duckAmbient(level = 0.15) { if (!this.ambientGain) return; this.ambientGain.gain.linearRampToValueAtTime(level, this.ctx.currentTime + 0.3); } unduckAmbient(level = 0.3) { if (!this.ambientGain) return; this.ambientGain.gain.linearRampToValueAtTime(level, this.ctx.currentTime + 0.5); } } // ═══════════════════════════════════════════════════════════════ // INTRO SEQUENCE // ═══════════════════════════════════════════════════════════════ /** * Orchestrates the intro: ticket → conductor box → materialization → cockpit * Replaces the existing 32s facility walkthrough. * Total duration: ~20 seconds */ async function playIntroSequence(audioManager) { const container = document.getElementById('tte-intro-container'); // Preload intro sounds await Promise.all([ audioManager.preload('ticket_clang', ASSETS.audio.sfx.ticket_clang), audioManager.preload('charge_up', ASSETS.audio.sfx.charge_up), audioManager.preload('gears_grinding', ASSETS.audio.sfx.gears_grinding), audioManager.preload('shockwave', ASSETS.audio.sfx.shockwave), audioManager.preload('materialize', ASSETS.audio.sfx.materialize), audioManager.preload('train_whistle', ASSETS.audio.sfx.train_whistle), audioManager.preload('steam_burst', ASSETS.audio.sfx.steam_burst), ]); // BEAT 1: Ticket appears (0-6s) const ticket = container.querySelector('.tte-intro-ticket'); ticket.style.opacity = '1'; ticket.style.animation = 'tte-ticket-float 6s ease-in-out forwards'; // Start low procedural hum (reuse existing Web Audio oscillator pattern) await sleep(6000); // BEAT 2: Conductor box + ticket punch (6-11s) const box = container.querySelector('.tte-intro-conductor-box'); box.style.opacity = '1'; box.style.transform = 'scale(1)'; await sleep(500); audioManager.playSFX('ticket_clang'); // Screen flash container.querySelector('.tte-intro-flash').style.opacity = '0.8'; await sleep(100); container.querySelector('.tte-intro-flash').style.opacity = '0'; await sleep(1000); audioManager.playSFX('charge_up'); await sleep(2000); audioManager.playSFX('gears_grinding'); // Energy lines begin container.querySelector('.tte-energy-lines').classList.add('active'); await sleep(1500); // BEAT 3: Materialization (11-18s) audioManager.playSFX('shockwave'); audioManager.playSFX('materialize'); // Screen shake container.style.animation = 'tte-screen-shake 0.3s ease-in-out 3'; await sleep(500); audioManager.playSFX('train_whistle'); // Train fades in const train = container.querySelector('.tte-intro-train'); train.style.opacity = '1'; train.style.transform = 'scale(1)'; train.style.filter = 'brightness(1)'; await sleep(3000); audioManager.playSFX('steam_burst'); await sleep(3500); // BEAT 4: Transition to cockpit (18-22s) // Flash to white, then fade to cockpit container.querySelector('.tte-intro-flash').style.opacity = '1'; container.querySelector('.tte-intro-flash').style.background = 'white'; await sleep(500); // Redirect to cockpit view (same pattern as current intro.js) window.location.href = '/app'; } // ═══════════════════════════════════════════════════════════════ // TRAVEL SEQUENCE — ERA SIGNS // ═══════════════════════════════════════════════════════════════ /** * Spawns era signs that fly across the viewport during travel. * Signs accelerate as the train picks up speed. * * @param {string} direction - 'future' or 'past' * @param {number} targetYear - destination year * @param {number} durationMs - total travel duration */ function spawnEraSigns(direction, targetYear, durationMs) { const viewport = document.querySelector('.tte-viewport'); const signs = ERA_SIGNS[direction] || ERA_SIGNS.past; // Filter signs to only show those between current year and target const currentYear = new Date().getFullYear(); const relevantSigns = signs.filter(s => { if (direction === 'past') return s.year < currentYear && s.year >= targetYear; return s.year > currentYear && s.year <= targetYear; }); const signCount = Math.min(relevantSigns.length, 8); // Max 8 signs const interval = durationMs / (signCount + 1); relevantSigns.slice(0, signCount).forEach((sign, i) => { setTimeout(() => { const el = document.createElement('div'); el.className = `tte-era-sign ${direction}`; el.textContent = sign.text; // Signs get faster as travel progresses const speed = Math.max(0.3, 1.5 - (i * 0.15)); el.style.setProperty('--sign-speed', `${speed}s`); // Vary vertical position for depth el.style.top = `${30 + Math.random() * 30}%`; viewport.appendChild(el); // Remove after animation el.addEventListener('animationend', () => el.remove()); }, interval * (i + 1)); }); } // ═══════════════════════════════════════════════════════════════ // YEAR COUNTER ANIMATION // ═══════════════════════════════════════════════════════════════ /** * Animates the year counter from current year to target year. * Uses eased counting for dramatic effect. * * @param {number} from - starting year * @param {number} to - target year * @param {number} durationMs - animation duration */ function animateYearCounter(from, to, durationMs) { const counterEl = document.querySelector('.tte-year-counter .tte-year-value'); if (!counterEl) return; const startTime = performance.now(); const diff = to - from; function update(currentTime) { const elapsed = currentTime - startTime; const progress = Math.min(elapsed / durationMs, 1); // Cubic ease-in-out for dramatic feel const eased = progress < 0.5 ? 4 * progress * progress * progress : 1 - Math.pow(-2 * progress + 2, 3) / 2; const currentYear = Math.round(from + diff * eased); // Format: negative years as "XXX BC" if (currentYear < 0) { counterEl.textContent = `${Math.abs(currentYear)} BC`; } else { counterEl.textContent = currentYear.toString(); } if (progress < 1) { requestAnimationFrame(update); } } requestAnimationFrame(update); } // ═══════════════════════════════════════════════════════════════ // LANDING / STEAM REVEAL // ═══════════════════════════════════════════════════════════════ /** * Orchestrates the landing sequence: * 1. Brake screech + violent shake * 2. Steam cloud covers viewport * 3. AI world image loads behind steam * 4. Steam clears to reveal destination * 5. Chime plays, narration starts */ async function playLandingSequence(audioManager, worldImageB64, ambientKey) { const cockpit = document.querySelector('.tte-cockpit'); const steamCloud = document.querySelector('.tte-steam-cloud'); const viewport = document.querySelector('.tte-viewport-content'); // 1. Braking (0-3s) cockpit.classList.add('tte-state-braking'); audioManager.playSFX('brake_screech'); await sleep(2000); // 2. Steam burst (2-3s) audioManager.playSFX('steam_burst'); steamCloud.classList.add('active'); cockpit.classList.remove('tte-state-braking'); cockpit.classList.remove('tte-state-traveling'); await sleep(1000); // 3. Load world image behind steam (3-5s) if (worldImageB64) { viewport.src = `data:image/png;base64,${worldImageB64}`; } // Start era ambient if (ambientKey && ASSETS.audio.ambient[ambientKey]) { audioManager.playAmbient(ambientKey, 3000); } await sleep(2000); // 4. Steam clears (5-8s) steamCloud.classList.remove('active'); await sleep(1000); // 5. Arrival chime audioManager.playSFX('chime'); cockpit.classList.add('tte-state-conversation'); } // ═══════════════════════════════════════════════════════════════ // UTILITY // ═══════════════════════════════════════════════════════════════ function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } /** * Preload all critical images to avoid flicker during transitions */ function preloadImages(urls) { return Promise.all(urls.map(url => { return new Promise((resolve, reject) => { const img = new Image(); img.onload = resolve; img.onerror = reject; img.src = url; }); })); } // Export for use in cockpit.js integration // window.TTE = { AudioManager, playIntroSequence, spawnEraSigns, animateYearCounter, playLandingSequence, ASSETS, ERA_SIGNS };