UNPKG

yukinovel

Version:

Yukinovel is a simple web visual novel engine.

336 lines (335 loc) 12.3 kB
export class AnimationManager { constructor() { this.activeAnimations = new Map(); this.transitionEffects = new Map(); this.registerDefaultTransitions(); } // Đăng ký các transition effects mặc định registerDefaultTransitions() { const effects = [ { name: 'fade', duration: 500, easing: 'ease-in-out' }, { name: 'slideLeft', duration: 700, easing: 'cubic-bezier(0.4, 0, 0.2, 1)', customCSS: 'transform: translateX(-100%);' }, { name: 'slideRight', duration: 700, easing: 'cubic-bezier(0.4, 0, 0.2, 1)', customCSS: 'transform: translateX(100%);' }, { name: 'slideUp', duration: 700, easing: 'cubic-bezier(0.4, 0, 0.2, 1)', customCSS: 'transform: translateY(-100%);' }, { name: 'slideDown', duration: 700, easing: 'cubic-bezier(0.4, 0, 0.2, 1)', customCSS: 'transform: translateY(100%);' }, { name: 'zoomIn', duration: 600, easing: 'ease-out', customCSS: 'transform: scale(0); opacity: 0;' }, { name: 'zoomOut', duration: 600, easing: 'ease-in', customCSS: 'transform: scale(1.2); opacity: 0;' }, { name: 'rotateIn', duration: 800, easing: 'ease-out', customCSS: 'transform: rotate(-180deg) scale(0); opacity: 0;' }, { name: 'bounce', duration: 1000, easing: 'cubic-bezier(0.68, -0.55, 0.265, 1.55)', customCSS: 'transform: scale(0); opacity: 0;' }, { name: 'flipX', duration: 800, easing: 'ease-in-out', customCSS: 'transform: rotateY(90deg); opacity: 0;' }, { name: 'flipY', duration: 800, easing: 'ease-in-out', customCSS: 'transform: rotateX(90deg); opacity: 0;' }, { name: 'shake', duration: 600, easing: 'ease-in-out', customCSS: 'animation: shake 0.6s ease-in-out;' }, { name: 'pulse', duration: 1000, easing: 'ease-in-out', customCSS: 'animation: pulse 1s ease-in-out;' }, { name: 'glitch', duration: 1200, easing: 'linear', customCSS: 'animation: glitch 1.2s linear;' } ]; effects.forEach(effect => { this.transitionEffects.set(effect.name, effect); }); } // Animate element với Web Animations API animateElement(element, keyframes, config = {}) { const options = { duration: config.duration || 500, easing: config.easing || 'ease', delay: config.delay || 0, iterations: config.repeat === 'infinite' ? Infinity : (config.repeat || 1), direction: config.direction || 'normal', fill: config.fillMode || 'both' }; const animation = element.animate(keyframes, options); const animationId = `${element.id || 'element'}_${Date.now()}`; this.activeAnimations.set(animationId, animation); // Update callback if (config.onUpdate) { const updateInterval = setInterval(() => { const currentTime = animation.currentTime; const duration = options.duration; if (currentTime !== null && typeof duration === 'number') { const progress = currentTime / duration; config.onUpdate(Math.min(progress, 1)); } if (animation.playState === 'finished') { clearInterval(updateInterval); } }, 16); // ~60fps } return new Promise((resolve) => { animation.addEventListener('finish', () => { this.activeAnimations.delete(animationId); if (config.onComplete) { config.onComplete(); } resolve(); }); }); } // Animate character entrance animateCharacterEntrance(element, effectName = 'fade', position = {}) { const effect = this.transitionEffects.get(effectName); if (!effect) { throw new Error(`Unknown transition effect: ${effectName}`); } // Set initial position if specified if (position.x !== undefined) { element.style.left = `${position.x}px`; } if (position.y !== undefined) { element.style.top = `${position.y}px`; } const keyframes = this.getEntranceKeyframes(effectName); return this.animateElement(element, keyframes, { duration: effect.duration, easing: effect.easing }); } // Animate character exit animateCharacterExit(element, effectName = 'fade') { const effect = this.transitionEffects.get(effectName); if (!effect) { throw new Error(`Unknown transition effect: ${effectName}`); } const keyframes = this.getExitKeyframes(effectName); return this.animateElement(element, keyframes, { duration: effect.duration, easing: effect.easing }); } // Scene transition animateSceneTransition(outElement, inElement, effectName = 'fade') { const effect = this.transitionEffects.get(effectName); if (!effect) { throw new Error(`Unknown transition effect: ${effectName}`); } const outKeyframes = this.getExitKeyframes(effectName); const inKeyframes = this.getEntranceKeyframes(effectName); // Animate out first, then in return this.animateElement(outElement, outKeyframes, { duration: effect.duration / 2, easing: effect.easing }).then(() => { return this.animateElement(inElement, inKeyframes, { duration: effect.duration / 2, easing: effect.easing }); }); } // Text typewriter effect animateTypewriter(element, text, speed = 50) { return new Promise((resolve) => { element.textContent = ''; let i = 0; const typeInterval = setInterval(() => { if (i < text.length) { element.textContent += text.charAt(i); i++; } else { clearInterval(typeInterval); resolve(); } }, speed); }); } // Screen shake effect shakeScreen(intensity = 10, duration = 500) { const gameContainer = document.querySelector('#game-container'); if (!gameContainer) return Promise.resolve(); const keyframes = [ { transform: 'translate(0)' }, { transform: `translate(${intensity}px, ${intensity}px)` }, { transform: `translate(-${intensity}px, -${intensity}px)` }, { transform: `translate(${intensity}px, -${intensity}px)` }, { transform: `translate(-${intensity}px, ${intensity}px)` }, { transform: 'translate(0)' } ]; return this.animateElement(gameContainer, keyframes, { duration, easing: 'ease-in-out' }); } // Parallax effect createParallaxEffect(elements, speeds, direction = 'horizontal') { let animationId; const animate = () => { const scrollPos = direction === 'horizontal' ? window.scrollX : window.scrollY; elements.forEach((element, index) => { const speed = speeds[index] || 1; const transform = direction === 'horizontal' ? `translateX(${scrollPos * speed}px)` : `translateY(${scrollPos * speed}px)`; element.style.transform = transform; }); animationId = requestAnimationFrame(animate); }; animate(); } // Particle effects createParticleEffect(container, particleCount = 50, config = {}) { const particles = []; for (let i = 0; i < particleCount; i++) { const particle = document.createElement('div'); particle.style.cssText = ` position: absolute; width: ${config.size || 4}px; height: ${config.size || 4}px; background: ${config.color || '#ffffff'}; border-radius: 50%; pointer-events: none; left: ${Math.random() * container.offsetWidth}px; top: ${Math.random() * container.offsetHeight}px; `; container.appendChild(particle); particles.push(particle); // Animate particle const keyframes = [ { transform: 'translate(0, 0) scale(1)', opacity: 1 }, { transform: `translate(${(Math.random() - 0.5) * 200}px, ${-Math.random() * 200}px) scale(0)`, opacity: 0 } ]; this.animateElement(particle, keyframes, { duration: config.life || 2000, easing: 'ease-out', onComplete: () => { particle.remove(); } }); } } // Get keyframes for entrance effects getEntranceKeyframes(effectName) { switch (effectName) { case 'fade': return [ { opacity: 0 }, { opacity: 1 } ]; case 'slideLeft': return [ { transform: 'translateX(-100%)', opacity: 0 }, { transform: 'translateX(0)', opacity: 1 } ]; case 'slideRight': return [ { transform: 'translateX(100%)', opacity: 0 }, { transform: 'translateX(0)', opacity: 1 } ]; case 'slideUp': return [ { transform: 'translateY(100%)', opacity: 0 }, { transform: 'translateY(0)', opacity: 1 } ]; case 'slideDown': return [ { transform: 'translateY(-100%)', opacity: 0 }, { transform: 'translateY(0)', opacity: 1 } ]; case 'zoomIn': return [ { transform: 'scale(0)', opacity: 0 }, { transform: 'scale(1)', opacity: 1 } ]; case 'bounce': return [ { transform: 'scale(0)', opacity: 0 }, { transform: 'scale(1.1)', opacity: 0.8, offset: 0.8 }, { transform: 'scale(1)', opacity: 1 } ]; default: return [ { opacity: 0 }, { opacity: 1 } ]; } } // Get keyframes for exit effects getExitKeyframes(effectName) { const entranceKeyframes = this.getEntranceKeyframes(effectName); return [...entranceKeyframes].reverse(); } // Stop all animations stopAllAnimations() { this.activeAnimations.forEach(animation => { animation.cancel(); }); this.activeAnimations.clear(); } // Register custom transition effect registerTransitionEffect(name, effect) { this.transitionEffects.set(name, effect); } }