| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
|
|
|
|
|
|
|
|
|
|
| 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',
|
|
|
| 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',
|
| }
|
| }
|
| };
|
|
|
|
|
| const ERA_SIGNS = {
|
|
|
| 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: [
|
| { 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 },
|
| ]
|
| };
|
|
|
|
|
|
|
|
|
|
|
|
|
| class AudioManager {
|
| constructor() {
|
| this.ctx = null;
|
| 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) {
|
|
|
| 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);
|
|
|
|
|
| gain.gain.setValueAtTime(0, this.ctx.currentTime);
|
| gain.gain.linearRampToValueAtTime(1, this.ctx.currentTime + fadeInMs / 1000);
|
|
|
| source.start();
|
| this.currentAmbient = gain;
|
| return source;
|
| }
|
|
|
|
|
| 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);
|
| }
|
| }
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
| |
| |
| |
|
|
| async function playIntroSequence(audioManager) {
|
| const container = document.getElementById('tte-intro-container');
|
|
|
|
|
| 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),
|
| ]);
|
|
|
|
|
| const ticket = container.querySelector('.tte-intro-ticket');
|
| ticket.style.opacity = '1';
|
| ticket.style.animation = 'tte-ticket-float 6s ease-in-out forwards';
|
|
|
|
|
| await sleep(6000);
|
|
|
|
|
| const box = container.querySelector('.tte-intro-conductor-box');
|
| box.style.opacity = '1';
|
| box.style.transform = 'scale(1)';
|
|
|
| await sleep(500);
|
| audioManager.playSFX('ticket_clang');
|
|
|
| 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');
|
|
|
| container.querySelector('.tte-energy-lines').classList.add('active');
|
|
|
| await sleep(1500);
|
|
|
|
|
| audioManager.playSFX('shockwave');
|
| audioManager.playSFX('materialize');
|
|
|
| container.style.animation = 'tte-screen-shake 0.3s ease-in-out 3';
|
|
|
| await sleep(500);
|
| audioManager.playSFX('train_whistle');
|
|
|
| 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);
|
|
|
|
|
|
|
| container.querySelector('.tte-intro-flash').style.opacity = '1';
|
| container.querySelector('.tte-intro-flash').style.background = 'white';
|
|
|
| await sleep(500);
|
|
|
| window.location.href = '/app';
|
| }
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
|
|
| function spawnEraSigns(direction, targetYear, durationMs) {
|
| const viewport = document.querySelector('.tte-viewport');
|
| const signs = ERA_SIGNS[direction] || ERA_SIGNS.past;
|
|
|
|
|
| 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);
|
| 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;
|
|
|
|
|
| const speed = Math.max(0.3, 1.5 - (i * 0.15));
|
| el.style.setProperty('--sign-speed', `${speed}s`);
|
|
|
|
|
| el.style.top = `${30 + Math.random() * 30}%`;
|
|
|
| viewport.appendChild(el);
|
|
|
|
|
| el.addEventListener('animationend', () => el.remove());
|
| }, interval * (i + 1));
|
| });
|
| }
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
|
|
| 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);
|
|
|
|
|
| const eased = progress < 0.5
|
| ? 4 * progress * progress * progress
|
| : 1 - Math.pow(-2 * progress + 2, 3) / 2;
|
|
|
| const currentYear = Math.round(from + diff * eased);
|
|
|
|
|
| if (currentYear < 0) {
|
| counterEl.textContent = `${Math.abs(currentYear)} BC`;
|
| } else {
|
| counterEl.textContent = currentYear.toString();
|
| }
|
|
|
| if (progress < 1) {
|
| requestAnimationFrame(update);
|
| }
|
| }
|
|
|
| requestAnimationFrame(update);
|
| }
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
|
|
| 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');
|
|
|
|
|
| cockpit.classList.add('tte-state-braking');
|
| audioManager.playSFX('brake_screech');
|
|
|
| await sleep(2000);
|
|
|
|
|
| audioManager.playSFX('steam_burst');
|
| steamCloud.classList.add('active');
|
| cockpit.classList.remove('tte-state-braking');
|
| cockpit.classList.remove('tte-state-traveling');
|
|
|
| await sleep(1000);
|
|
|
|
|
| if (worldImageB64) {
|
| viewport.src = `data:image/png;base64,${worldImageB64}`;
|
| }
|
|
|
|
|
| if (ambientKey && ASSETS.audio.ambient[ambientKey]) {
|
| audioManager.playAmbient(ambientKey, 3000);
|
| }
|
|
|
| await sleep(2000);
|
|
|
|
|
| steamCloud.classList.remove('active');
|
|
|
| await sleep(1000);
|
|
|
|
|
| audioManager.playSFX('chime');
|
|
|
| cockpit.classList.add('tte-state-conversation');
|
| }
|
|
|
|
|
|
|
|
|
|
|
|
|
| function sleep(ms) {
|
| return new Promise(resolve => setTimeout(resolve, ms));
|
| }
|
|
|
| |
| |
|
|
| 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;
|
| });
|
| }));
|
| }
|
|
|
|
|
|
|
|
|