UNPKG

yukinovel

Version:

Yukinovel is a simple web visual novel engine.

421 lines (420 loc) 20.5 kB
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; } } } } }); } }