yukinovel
Version:
Yukinovel is a simple web visual novel engine.
421 lines (420 loc) • 20.5 kB
JavaScript
export class SceneRenderer {
constructor(game, backgroundElement, characterContainer) {
this.backgroundVideo = null;
this.originalCharacterSprites = {};
this.currentBackgroundUrl = '';
this.fadeAnimationDuration = 500;
this.game = game;
this.backgroundElement = backgroundElement;
this.characterContainer = characterContainer;
}
updateScene(scene) {
this.updateSceneBackground(scene);
this.updateCharacters(scene.characters || [], false);
if (scene.characters) {
scene.characters.forEach(character => {
this.originalCharacterSprites[character.name] = character.image;
});
}
}
updateSceneWithFade(scene, shouldFadeBackground = false, backgroundAnimation) {
if (shouldFadeBackground) {
const animationConfig = backgroundAnimation ? { animation: backgroundAnimation.animation } : undefined;
this.updateSceneBackgroundWithFade(scene, true, animationConfig);
this.updateCharacters(scene.characters || [], true);
}
else {
this.updateSceneBackground(scene);
this.updateCharacters(scene.characters || [], false);
}
if (scene.characters) {
scene.characters.forEach(character => {
this.originalCharacterSprites[character.name] = character.image;
});
}
}
updateCharacterSprites(dialogue) {
const characterName = dialogue.character;
const character = characterName ? this.game.getScript().characters[characterName] : null;
const shouldUseFade = dialogue.fadeAnimation?.enabled === true;
const fadeAnimationConfig = dialogue.fadeAnimation;
const shouldCharacterFade = (charName) => {
if (!shouldUseFade)
return { enabled: false };
const characterFade = fadeAnimationConfig?.characterFade;
if (typeof characterFade === 'boolean') {
return { enabled: characterFade, duration: fadeAnimationConfig?.duration };
}
if (typeof characterFade === 'object' && characterFade !== null) {
const charConfig = characterFade[charName];
if (typeof charConfig === 'boolean') {
return { enabled: charConfig, duration: fadeAnimationConfig?.duration };
}
if (typeof charConfig === 'object' && charConfig !== null) {
return {
enabled: charConfig.enabled,
duration: charConfig.duration || fadeAnimationConfig?.duration,
animation: charConfig.animation
};
}
}
return { enabled: true, duration: fadeAnimationConfig?.duration };
};
if (character && dialogue.emotion && character.emotions) {
const fadeConfig = shouldCharacterFade(characterName);
this.updateCharacterEmotion(characterName, dialogue.emotion, fadeConfig.enabled, fadeConfig.duration);
}
if (character && dialogue.sprite) {
const fadeConfig = shouldCharacterFade(characterName);
this.updateCharacterSprite(characterName, dialogue.sprite, fadeConfig.enabled, fadeConfig.duration, fadeConfig);
}
if (dialogue.characterSprite) {
Object.keys(dialogue.characterSprite).forEach(charName => {
const fadeConfig = shouldCharacterFade(charName);
this.updateCharacterSprite(charName, dialogue.characterSprite[charName], fadeConfig.enabled, fadeConfig.duration, fadeConfig);
});
}
this.restoreUnspecifiedCharacterSprites(dialogue, shouldUseFade, fadeAnimationConfig);
}
setFadeAnimationDuration(duration) {
this.fadeAnimationDuration = duration;
this.backgroundElement.style.transition = `opacity ${duration}ms ease-in-out`;
}
updateSceneBackground(scene) {
const backgroundUrl = scene.backgroundVideo || scene.background;
if (backgroundUrl === this.currentBackgroundUrl)
return;
if (!backgroundUrl) {
this.backgroundElement.style.transition = 'none';
this.clearBackground();
this.currentBackgroundUrl = '';
return;
}
this.backgroundElement.style.transition = 'none';
this.setNewBackground(scene, backgroundUrl);
this.currentBackgroundUrl = backgroundUrl;
this.backgroundElement.style.opacity = '1';
}
updateSceneBackgroundWithFade(scene, shouldFade, fadeConfig) {
const backgroundUrl = scene.backgroundVideo || scene.background;
if (backgroundUrl === this.currentBackgroundUrl)
return;
if (!backgroundUrl) {
if (shouldFade) {
const outAnimation = fadeConfig?.animation ?
(fadeConfig.animation.endsWith('In') ?
fadeConfig.animation.replace(/In$/, 'Out') :
'fadeOut') :
'fadeOut';
this.fadeOutBackground(() => {
this.clearBackground();
this.currentBackgroundUrl = '';
}, outAnimation);
}
else {
this.clearBackground();
this.currentBackgroundUrl = '';
}
return;
}
if (shouldFade) {
const outAnimation = fadeConfig?.animation ?
(fadeConfig.animation.endsWith('In') ?
fadeConfig.animation.replace(/In$/, 'Out') :
'fadeOut') :
'fadeOut';
const inAnimation = fadeConfig?.animation || 'fadeIn';
this.fadeOutBackground(() => {
this.setNewBackground(scene, backgroundUrl);
this.currentBackgroundUrl = backgroundUrl;
this.fadeInBackground(inAnimation);
}, outAnimation);
}
else {
this.setNewBackground(scene, backgroundUrl);
this.currentBackgroundUrl = backgroundUrl;
this.backgroundElement.style.opacity = '1';
}
}
getScaleAwareAnimation(animation) {
const scaleAwareAnimations = {
'fadeIn': 'fadeInWithScale',
'fadeOut': 'fadeOutWithScale',
'fadeInLeft': 'fadeInLeftWithScale',
'fadeInRight': 'fadeInRightWithScale',
'fadeInUp': 'fadeInUpWithScale',
'fadeInDown': 'fadeInDownWithScale',
'slideInLeft': 'slideInLeftWithScale',
'slideInRight': 'slideInRightWithScale',
'slideInUp': 'slideInUpWithScale',
'slideInDown': 'slideInDownWithScale',
'bounceIn': 'bounceInWithScale',
'zoomIn': 'zoomInWithScale'
};
return scaleAwareAnimations[animation] || animation;
}
fadeOutCharacter(charElement, callback, customDuration, animation = 'fadeOut') {
const duration = customDuration || this.fadeAnimationDuration;
// console.log(`Character fade out: ${animation}, duration: ${duration}ms`);
charElement.classList.remove('animate__animated');
charElement.className = charElement.className.replace(/animate__\w+/g, '');
charElement.style.setProperty('--animate-duration', `${duration}ms`);
charElement.offsetHeight;
const scaleAwareAnimation = this.getScaleAwareAnimation(animation);
charElement.classList.add('animate__animated', `animate__${scaleAwareAnimation}`);
const handleAnimationEnd = () => {
// console.log(`Character fade out completed: ${scaleAwareAnimation}`);
charElement.classList.remove('animate__animated', `animate__${scaleAwareAnimation}`);
charElement.removeEventListener('animationend', handleAnimationEnd);
callback();
};
charElement.addEventListener('animationend', handleAnimationEnd);
}
fadeInCharacter(charElement, customDuration, animation = 'fadeIn') {
const duration = customDuration || this.fadeAnimationDuration;
// console.log(`Character fade in: ${animation}, duration: ${duration}ms`);
charElement.classList.remove('animate__animated');
charElement.className = charElement.className.replace(/animate__\w+/g, '');
charElement.style.setProperty('--animate-duration', `${duration}ms`);
charElement.offsetHeight;
const scaleAwareAnimation = this.getScaleAwareAnimation(animation);
charElement.classList.add('animate__animated', `animate__${scaleAwareAnimation}`);
const handleAnimationEnd = () => {
// console.log(`Character fade in completed: ${scaleAwareAnimation}`);
charElement.classList.remove('animate__animated', `animate__${scaleAwareAnimation}`);
charElement.removeEventListener('animationend', handleAnimationEnd);
};
charElement.addEventListener('animationend', handleAnimationEnd);
}
fadeOutBackground(callback, animation = 'fadeOut') {
this.backgroundElement.classList.remove('animate__animated');
this.backgroundElement.className = this.backgroundElement.className.replace(/animate__\w+/g, '');
this.backgroundElement.style.setProperty('--animate-duration', `${this.fadeAnimationDuration}ms`);
this.backgroundElement.offsetHeight;
this.backgroundElement.classList.add('animate__animated', `animate__${animation}`);
const handleAnimationEnd = () => {
this.backgroundElement.classList.remove('animate__animated', `animate__${animation}`);
this.backgroundElement.removeEventListener('animationend', handleAnimationEnd);
callback();
};
this.backgroundElement.addEventListener('animationend', handleAnimationEnd);
}
fadeInBackground(animation = 'fadeIn') {
this.backgroundElement.classList.remove('animate__animated');
this.backgroundElement.className = this.backgroundElement.className.replace(/animate__\w+/g, '');
this.backgroundElement.style.setProperty('--animate-duration', `${this.fadeAnimationDuration}ms`);
this.backgroundElement.offsetHeight;
this.backgroundElement.classList.add('animate__animated', `animate__${animation}`);
const handleAnimationEnd = () => {
this.backgroundElement.classList.remove('animate__animated', `animate__${animation}`);
this.backgroundElement.removeEventListener('animationend', handleAnimationEnd);
};
this.backgroundElement.addEventListener('animationend', handleAnimationEnd);
}
clearBackground() {
if (this.backgroundVideo) {
this.backgroundVideo.pause();
this.backgroundVideo.remove();
this.backgroundVideo = null;
}
this.backgroundElement.style.backgroundImage = '';
this.backgroundElement.innerHTML = '';
}
setNewBackground(scene, backgroundUrl) {
this.clearBackground();
let backgroundType = scene.backgroundType || 'auto';
if (backgroundType === 'auto') {
backgroundType = this.detectBackgroundType(backgroundUrl);
}
switch (backgroundType) {
case 'video':
this.backgroundVideo = this.setupBackgroundVideo(backgroundUrl);
this.backgroundVideo.className = 'vn-background-video';
this.backgroundElement.appendChild(this.backgroundVideo);
break;
case 'gif':
case 'image':
default:
this.backgroundElement.style.backgroundImage = `url(${backgroundUrl})`;
break;
}
}
detectBackgroundType(url) {
const extension = url.toLowerCase().split('.').pop();
if (extension === 'mp4' || extension === 'webm' || extension === 'ogg') {
return 'video';
}
else if (extension === 'gif') {
return 'gif';
}
else {
return 'image';
}
}
setupBackgroundVideo(url) {
const video = document.createElement('video');
video.src = url;
video.autoplay = true;
video.loop = true;
video.muted = true;
video.playsInline = true;
return video;
}
updateCharacters(characters, fadeInNewCharacters = false) {
const existingCharacters = Array.from(this.characterContainer.children);
const newCharacterNames = characters.map(char => char.name);
const existingCharacterNames = existingCharacters.map(el => el.id.replace('character-', ''));
existingCharacterNames.forEach(charName => {
if (!newCharacterNames.includes(charName)) {
const charElement = document.getElementById(`character-${charName}`);
if (charElement) {
if (fadeInNewCharacters) {
this.fadeOutCharacter(charElement, () => {
charElement.remove();
});
}
else {
charElement.remove();
}
}
}
});
characters.forEach((character, index) => {
let charElement = document.getElementById(`character-${character.name}`);
const isNewCharacter = !charElement;
if (isNewCharacter) {
charElement = document.createElement('div');
charElement.id = `character-${character.name}`;
charElement.className = 'vn-character-element';
this.characterContainer.appendChild(charElement);
}
if (!charElement)
return;
const position = character.position || {};
const x = position.x !== undefined ? position.x : (20 + index * 200);
const y = position.y !== undefined ? position.y : 0;
const width = position.width || 300;
const height = position.height || 400;
const scale = position.scale || 1;
const xValue = typeof x === 'string' ? x : `${x}px`;
const yValue = typeof y === 'string' ? y : `${y}px`;
charElement.style.bottom = yValue;
charElement.style.left = xValue;
charElement.style.width = typeof width === 'string' ? width : `${width}px`;
charElement.style.height = typeof height === 'string' ? height : `${height}px`;
charElement.style.backgroundImage = `url(${character.image || ''})`;
// Sử dụng CSS custom property để lưu scale
charElement.style.setProperty('--character-scale', scale.toString());
charElement.style.transform = `scale(${scale})`;
if (isNewCharacter && fadeInNewCharacters) {
charElement.style.opacity = '0';
setTimeout(() => {
charElement.style.opacity = '1';
this.fadeInCharacter(charElement);
}, 50);
}
});
}
updateCharacterEmotion(characterName, emotion, shouldFade, customDuration) {
const character = this.game.getScript().characters[characterName];
if (character && character.emotions && character.emotions[emotion]) {
const charElement = document.getElementById(`character-${characterName}`);
if (charElement) {
if (shouldFade) {
this.fadeOutCharacter(charElement, () => {
charElement.style.backgroundImage = `url(${character.emotions[emotion]})`;
this.fadeInCharacter(charElement, customDuration);
}, customDuration);
}
else {
charElement.style.backgroundImage = `url(${character.emotions[emotion]})`;
}
}
}
}
updateCharacterSprite(characterName, sprite, shouldFade, customDuration, fadeConfig) {
const charElement = document.getElementById(`character-${characterName}`);
if (charElement) {
const currentSprite = charElement.style.backgroundImage;
const newSprite = sprite ? `url(${sprite})` : '';
if (currentSprite !== newSprite) {
if (shouldFade) {
const outAnimation = fadeConfig?.animation ?
(fadeConfig.animation.endsWith('In') ?
fadeConfig.animation.replace(/In$/, 'Out') :
'fadeOut') :
'fadeOut';
const inAnimation = fadeConfig?.animation || 'fadeIn';
this.fadeOutCharacter(charElement, () => {
charElement.style.backgroundImage = newSprite;
this.fadeInCharacter(charElement, customDuration, inAnimation);
}, customDuration, outAnimation);
}
else {
charElement.style.backgroundImage = newSprite;
}
}
}
}
restoreUnspecifiedCharacterSprites(dialogue, shouldUseFade, fadeAnimationConfig) {
const specifiedCharacters = new Set();
if (dialogue.character && dialogue.sprite) {
specifiedCharacters.add(dialogue.character);
}
if (dialogue.characterSprite) {
Object.keys(dialogue.characterSprite).forEach(charName => {
specifiedCharacters.add(charName);
});
}
const shouldCharacterFade = (charName) => {
if (!shouldUseFade)
return { enabled: false };
const characterFade = fadeAnimationConfig?.characterFade;
if (typeof characterFade === 'boolean') {
return { enabled: characterFade, duration: fadeAnimationConfig?.duration };
}
if (typeof characterFade === 'object' && characterFade !== null) {
const charConfig = characterFade[charName];
if (typeof charConfig === 'boolean') {
return { enabled: charConfig, duration: fadeAnimationConfig?.duration };
}
if (typeof charConfig === 'object' && charConfig !== null) {
return {
enabled: charConfig.enabled,
duration: charConfig.duration || fadeAnimationConfig?.duration,
animation: charConfig.animation
};
}
}
return { enabled: true, duration: fadeAnimationConfig?.duration };
};
Object.keys(this.originalCharacterSprites).forEach(charName => {
if (!specifiedCharacters.has(charName)) {
const charElement = document.getElementById(`character-${charName}`);
if (charElement) {
const originalSprite = this.originalCharacterSprites[charName];
const currentSprite = charElement.style.backgroundImage;
const newSprite = originalSprite ? `url(${originalSprite})` : '';
if (currentSprite !== newSprite) {
const fadeConfig = shouldCharacterFade(charName);
if (fadeConfig.enabled) {
const outAnimation = fadeConfig.animation ?
(fadeConfig.animation.endsWith('In') ?
fadeConfig.animation.replace(/In$/, 'Out') :
'fadeOut') :
'fadeOut';
const inAnimation = fadeConfig.animation || 'fadeIn';
this.fadeOutCharacter(charElement, () => {
charElement.style.backgroundImage = newSprite;
this.fadeInCharacter(charElement, fadeConfig.duration, inAnimation);
}, fadeConfig.duration, outAnimation);
}
else {
charElement.style.backgroundImage = newSprite;
}
}
}
}
});
}
}