vod jam and earl vod.atverkackt.de
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

Force music to play with button, improve crash sequence a little bit

+221 -49
+221 -49
src/lib/game/IntroSequence.svelte
··· 16 16 * 'start' — "Find the VODs" button 17 17 */ 18 18 type Phase = 19 + | 'title' 19 20 | 'pre-crash' 20 21 | 'crash-0' | 'crash-1' | 'crash-2' | 'crash-3' | 'crash-4' 21 22 | 'post-silent' 22 23 | 'post-angry' 23 24 | 'start'; 24 25 25 - let phase = $state<Phase>('pre-crash'); 26 + let phase = $state<Phase>('title'); 26 27 let particles: Array<{ 27 28 id: number; 28 29 x: number; ··· 36 37 let containerEl: HTMLDivElement | undefined = $state(); 37 38 let isTouchDevice = $state(false); 38 39 40 + // Smoke particles 41 + let smokeParticles: Array<{ 42 + id: number; 43 + x: number; 44 + y: number; 45 + vx: number; 46 + vy: number; 47 + size: number; 48 + opacity: number; 49 + life: number; 50 + maxLife: number; 51 + }> = $state([]); 52 + let smokeId = 0; 53 + let smokeRaf: number | null = null; 54 + let lastSmokeTime = 0; 55 + let sparkInterval: ReturnType<typeof setInterval> | null = null; 56 + 39 57 // Typewriter state 40 58 let typewriterText = $state(''); 41 59 let typewriterFull = ''; ··· 55 73 oncomplete?.(); 56 74 } 57 75 76 + function startIntro() { 77 + handleInteract(); 78 + phase = 'pre-crash'; 79 + startTypewriter('Deliver the VODs from Atmosphere Conf', () => { 80 + setTimeout(() => { 81 + if (phase === 'pre-crash') { 82 + startCrash(); 83 + } 84 + }, 1500); 85 + }); 86 + } 87 + 58 88 /** Advance to the next dialog phase (skip on click/key). Cannot skip past 'start'. */ 59 89 function skipForward() { 90 + if (phase === 'title') return; // Must click Play button 60 91 if (phase === 'start') return; // Must click button 61 92 if (completed) return; 62 93 ··· 73 104 // Skip ahead in crash to post-silent 74 105 phase = 'crash-4'; 75 106 clearCrashTimers(); 107 + stopSmokeLoop(); 108 + stopSparkLoop(); 76 109 setTimeout(() => { phase = 'post-silent'; startPostSilent(); }, 300); 77 110 break; 78 111 case 'crash-4': ··· 90 123 } 91 124 92 125 function generateParticles() { 93 - particles = Array.from({ length: 20 }, (_, i) => ({ 94 - id: i, 95 - x: window.innerWidth * 0.4, 96 - y: window.innerHeight * 0.35, 97 - dx: (Math.random() - 0.5) * 100, 98 - dy: (Math.random() - 0.5) * 100, 99 - delay: Math.random() * 1.5 126 + // Positions relative to ship element (132×102 img) 127 + const count = 50; 128 + const base = particles.length > 0 ? Math.max(...particles.map(p => p.id)) + 1 : 0; 129 + const newParticles = Array.from({ length: count }, (_, i) => ({ 130 + id: base + i, 131 + x: 66 + (Math.random() - 0.5) * 50, 132 + y: 51 + (Math.random() - 0.5) * 40, 133 + dx: (Math.random() - 0.5) * 160, 134 + dy: (Math.random() - 0.5) * 160, 135 + delay: Math.random() * 0.6 100 136 })); 137 + particles = [...particles, ...newParticles]; 138 + // Clean up old particles after their animation finishes 139 + setTimeout(() => { 140 + particles = particles.filter(p => p.id >= base + count || newParticles.every(np => np.id !== p.id) === false); 141 + }, 2600); 142 + } 143 + 144 + function startSparkLoop() { 145 + generateParticles(); 146 + sparkInterval = setInterval(generateParticles, 800); 147 + } 148 + 149 + function stopSparkLoop() { 150 + if (sparkInterval !== null) { 151 + clearInterval(sparkInterval); 152 + sparkInterval = null; 153 + } 154 + particles = []; 155 + } 156 + 157 + // --- Smoke system --- 158 + function spawnSmoke() { 159 + // Positions relative to ship element center (132×102 img) 160 + for (let i = 0; i < 6; i++) { 161 + smokeParticles = [...smokeParticles, { 162 + id: smokeId++, 163 + x: 66 + (Math.random() - 0.5) * 50, 164 + y: 51 + (Math.random() - 0.3) * 40, 165 + vx: (Math.random() - 0.5) * 50, 166 + vy: -(Math.random() * 60 + 25), 167 + size: Math.random() * 28 + 14, 168 + opacity: 0.8 + Math.random() * 0.2, 169 + life: 0, 170 + maxLife: 1.5 + Math.random() * 1.5 171 + }]; 172 + } 173 + } 174 + 175 + function updateSmoke(dt: number) { 176 + smokeParticles = smokeParticles 177 + .map(p => ({ 178 + ...p, 179 + x: p.x + p.vx * dt, 180 + y: p.y + p.vy * dt, 181 + size: p.size + 15 * dt, 182 + life: p.life + dt, 183 + opacity: p.opacity * (1 - p.life / p.maxLife) 184 + })) 185 + .filter(p => p.life < p.maxLife); 186 + } 187 + 188 + function startSmokeLoop() { 189 + lastSmokeTime = performance.now(); 190 + function tick(now: number) { 191 + const dt = (now - lastSmokeTime) / 1000; 192 + lastSmokeTime = now; 193 + spawnSmoke(); 194 + updateSmoke(dt); 195 + smokeRaf = requestAnimationFrame(tick); 196 + } 197 + smokeRaf = requestAnimationFrame(tick); 198 + } 199 + 200 + function stopSmokeLoop() { 201 + if (smokeRaf !== null) { 202 + cancelAnimationFrame(smokeRaf); 203 + smokeRaf = null; 204 + } 205 + smokeParticles = []; 101 206 } 102 207 103 208 // --- Typewriter --- ··· 136 241 clearCrashTimers(); 137 242 138 243 crashTimers = [ 244 + // Ship arrives after 3s wobbly flight — sparks fly 139 245 setTimeout(() => { 140 246 phase = 'crash-1'; 141 - generateParticles(); 142 - }, 1000), 247 + startSparkLoop(); 248 + }, 3000), 249 + // Smoke starts billowing 143 250 setTimeout(() => { 144 251 phase = 'crash-2'; 145 - }, 2000), 252 + startSmokeLoop(); 253 + }, 4000), 254 + // Ship spirals down 146 255 setTimeout(() => { 147 256 phase = 'crash-3'; 148 - }, 3000), 257 + }, 5500), 258 + // Fade to black 149 259 setTimeout(() => { 150 260 phase = 'crash-4'; 151 - }, 3500), 261 + stopSparkLoop(); 262 + stopSmokeLoop(); 263 + }, 6000), 152 264 setTimeout(() => { 153 265 phase = 'post-silent'; 154 266 startPostSilent(); 155 - }, 4500), 267 + }, 7000), 156 268 ]; 157 269 } 158 270 ··· 184 296 $effect(() => { 185 297 isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0; 186 298 containerEl?.focus(); 187 - 188 - // Start Phase A: pre-crash dialog 189 - startTypewriter('Deliver the VODs from Atmosphere Conf', () => { 190 - // After text completes, pause then start crash 191 - setTimeout(() => { 192 - if (phase === 'pre-crash') { 193 - startCrash(); 194 - } 195 - }, 1500); 196 - }); 197 299 198 300 return () => { 199 301 clearTypewriter(); 200 302 clearCrashTimers(); 303 + stopSmokeLoop(); 304 + stopSparkLoop(); 201 305 }; 202 306 }); 203 307 ··· 220 324 role="button" 221 325 aria-label="Intro sequence — press any key to skip" 222 326 > 327 + <!-- ======= TITLE GATE ======= --> 328 + {#if phase === 'title'} 329 + <div class="title-scene"> 330 + <div class="starfield" style="background-image: url('/space.webp')"></div> 331 + <img src="/sprites/ship/ship.webp" alt="Ship" class="title-ship" /> 332 + <h1 class="title-heading"><span class="title-text-animated">VoD Jam</span></h1> 333 + <button 334 + class="start-btn" 335 + onclick={(e) => { e.stopPropagation(); startIntro(); }} 336 + onkeydown={(e) => { e.stopPropagation(); if (e.code === 'Enter' || e.code === 'Space') startIntro(); }} 337 + > 338 + Play Game 339 + </button> 340 + </div> 341 + {/if} 342 + 223 343 <!-- ======= PHASE A: Pre-crash dialog ======= --> 224 344 {#if phase === 'pre-crash'} 225 345 <div class="dialog-scene"> ··· 237 357 238 358 <div 239 359 class="ship" 240 - class:shaking={crashPhaseNum >= 1 && crashPhaseNum < 2} 241 - class:spiraling={crashPhaseNum >= 2} 360 + class:shaking={crashPhaseNum >= 1 && crashPhaseNum < 3} 361 + class:spiraling={crashPhaseNum >= 3} 242 362 > 243 363 <img src="/sprites/ship/ship.webp" alt="Ship" /> 364 + 365 + {#each particles as p (p.id)} 366 + <div 367 + class="particle" 368 + style="left:{p.x}px;top:{p.y}px;--dx:{p.dx}px;--dy:{p.dy}px;animation-delay:{p.delay}s" 369 + ></div> 370 + {/each} 371 + 372 + {#each smokeParticles as sp (sp.id)} 373 + <div 374 + class="smoke" 375 + style="left:{sp.x}px;top:{sp.y}px;width:{sp.size}px;height:{sp.size}px;opacity:{Math.max(0, sp.opacity)}" 376 + ></div> 377 + {/each} 244 378 </div> 245 379 246 - {#each particles as p (p.id)} 247 - <div 248 - class="particle" 249 - style="left:{p.x}px;top:{p.y}px;--dx:{p.dx}px;--dy:{p.dy}px;animation-delay:{p.delay}s" 250 - ></div> 251 - {/each} 252 - 253 - <div class="flash" class:active={crashPhaseNum >= 3}></div> 254 380 <div class="fade" class:active={crashPhaseNum >= 4}></div> 255 381 {/if} 256 382 ··· 445 571 top: 0; 446 572 left: 0; 447 573 image-rendering: pixelated; 448 - animation: fly-in 1s ease-out forwards; 574 + animation: fly-in 3s cubic-bezier(0.25, 0.1, 0.25, 1) forwards; 449 575 } 450 576 451 577 .ship img { ··· 456 582 457 583 .ship.shaking { 458 584 animation: 459 - fly-in 1s ease-out forwards, 585 + fly-in 3s cubic-bezier(0.25, 0.1, 0.25, 1) forwards, 460 586 shake 0.1s linear infinite; 461 587 } 462 588 463 589 .ship.spiraling { 464 - animation: spiral-down 1s ease-in forwards; 590 + animation: spiral-down 1.5s ease-in forwards; 465 591 } 466 592 593 + /* Lazy wobbly flight path — smooth sine-wave drift from off-screen right */ 467 594 @keyframes fly-in { 468 - from { transform: translate(100vw, -20vh) rotate(-15deg); } 469 - to { transform: translate(30vw, 30vh) rotate(-5deg); } 595 + 0% { transform: translate(105vw, 25vh) rotate(10deg); } 596 + 12% { transform: translate(88vw, 22vh) rotate(4deg); } 597 + 25% { transform: translate(72vw, 18vh) rotate(-6deg); } 598 + 37% { transform: translate(60vw, 24vh) rotate(-2deg); } 599 + 50% { transform: translate(50vw, 32vh) rotate(5deg); } 600 + 62% { transform: translate(43vw, 28vh) rotate(1deg); } 601 + 75% { transform: translate(37vw, 25vh) rotate(-4deg); } 602 + 87% { transform: translate(33vw, 28vh) rotate(-1deg); } 603 + 100% { transform: translate(30vw, 30vh) rotate(-5deg); } 470 604 } 471 605 472 606 @keyframes shake { ··· 494 628 from { opacity: 1; transform: translate(0, 0); } 495 629 to { opacity: 0; transform: translate(var(--dx), var(--dy)); } 496 630 } 497 - 498 - .flash { 499 - position: absolute; 500 - inset: 0; 501 - background: white; 502 - opacity: 0; 503 - transition: opacity 0.15s; 504 - pointer-events: none; 505 - } 506 - .flash.active { opacity: 1; } 507 631 508 632 .fade { 509 633 position: absolute; ··· 590 714 pointer-events: none; 591 715 margin: 0; 592 716 z-index: 10; 717 + } 718 + 719 + /* ===== Title gate scene ===== */ 720 + .title-scene { 721 + position: absolute; 722 + inset: 0; 723 + display: flex; 724 + flex-direction: column; 725 + align-items: center; 726 + justify-content: center; 727 + gap: 1.5rem; 728 + } 729 + 730 + .title-ship { 731 + width: 132px; 732 + height: auto; 733 + image-rendering: pixelated; 734 + animation: float 3s ease-in-out infinite; 735 + position: relative; 736 + z-index: 1; 737 + } 738 + 739 + @keyframes float { 740 + 0%, 100% { transform: translateY(0) rotate(-2deg); } 741 + 50% { transform: translateY(-15px) rotate(2deg); } 742 + } 743 + 744 + .title-heading { 745 + font-size: clamp(2rem, 6vw, 3.5rem); 746 + color: #FFD93D; 747 + margin: 0; 748 + text-shadow: 3px 3px 0 #0B0E17; 749 + position: relative; 750 + z-index: 1; 751 + } 752 + 753 + .title-text-animated { 754 + animation: font-switch 4s steps(1) infinite; 755 + } 756 + 757 + /* ===== Smoke particles ===== */ 758 + .smoke { 759 + position: absolute; 760 + border-radius: 50%; 761 + background: radial-gradient(circle, rgba(120, 120, 120, 0.6) 0%, rgba(60, 60, 60, 0.2) 60%, transparent 100%); 762 + pointer-events: none; 763 + z-index: 5; 764 + filter: blur(3px); 593 765 } 594 766 </style>