yukinovel
Version:
Yukinovel is a simple web visual novel engine.
336 lines (335 loc) • 12.3 kB
JavaScript
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);
}
}