| <!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>3D Model Animation Selector - Responsive</title> |
| <script src="https://cdn.tailwindcss.com"></script> |
| <style> |
| html, body, #main-wrapper, #viewer-content { |
| height: 100%; |
| margin: 0; |
| overflow: hidden; |
| } |
| canvas { |
| display: block; |
| } |
| .character-icon-container { |
| @apply w-14 h-14 lg:w-20 lg:h-20 rounded-full p-0.5 flex items-center justify-center cursor-pointer transition-all duration-200 border-2 border-transparent bg-gray-700/50 hover:bg-gray-600/70; |
| overflow: hidden; |
| box-shadow: 0 4px 10px rgba(0,0,0,0.3); |
| flex-shrink: 0; |
| } |
| .character-icon-container.active { |
| @apply border-accent bg-accent/20 ring-4 ring-accent/50; |
| } |
| |
| |
| #home-screen { |
| background-image: url('bckg_home.png'); |
| background-size: cover; |
| background-position: center; |
| } |
| .purple-overlay { |
| |
| background-color: rgba(99, 102, 241, 0.5); |
| } |
| |
| #mobile-controls-drawer { |
| transform: translateX(100%); |
| transition: transform 0.3s ease-in-out; |
| z-index: 50; |
| } |
| |
| #mobile-controls-drawer.open { |
| transform: translateX(0); |
| } |
| |
| #help-modal-overlay { |
| @apply fixed inset-0 bg-black/70 flex items-center justify-center z-[100] transition-opacity duration-300 opacity-0 pointer-events-none; |
| } |
| |
| #help-modal-overlay.visible { |
| @apply opacity-100 pointer-events-auto; |
| } |
| |
| |
| #action-buttons-container { |
| @apply fixed bottom-4 right-4 flex space-x-3 z-50; |
| } |
| |
| |
| input[type="range"] { |
| -webkit-appearance: none; |
| width: 100%; |
| height: 8px; |
| background: #4b5563; |
| border-radius: 4px; |
| outline: none; |
| transition: opacity .2s; |
| } |
| input[type="range"]::-webkit-slider-thumb { |
| -webkit-appearance: none; |
| appearance: none; |
| width: 18px; |
| height: 18px; |
| border-radius: 50%; |
| background: #60a5fa; |
| cursor: pointer; |
| box-shadow: 0 0 5px rgba(0, 0, 0, 0.5); |
| } |
| input[type="range"]::-moz-range-thumb { |
| width: 18px; |
| height: 18px; |
| border-radius: 50%; |
| background: #60a5fa; |
| cursor: pointer; |
| border: none; |
| box-shadow: 0 0 5px rgba(0, 0, 0, 0.5); |
| } |
| |
| </style> |
| <script> |
| tailwind.config = { |
| theme: { |
| extend: { |
| fontFamily: { |
| sans: ['Inter', 'sans-serif'], |
| }, |
| colors: { |
| 'primary-bg': '#1f2937', |
| 'secondary-bg': '#374151', |
| 'accent': '#60a5fa', |
| } |
| } |
| } |
| } |
| </script> |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script> |
| <script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/loaders/GLTFLoader.js"></script> |
| <script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js"></script> |
| |
| </head> |
| <body class="bg-primary-bg font-sans text-gray-100"> |
|
|
| <div id="main-wrapper" class="h-full w-full relative"> |
| |
| |
| <div id="home-screen" class="absolute inset-0 flex items-center justify-center z-[110]"> |
| <div class="purple-overlay absolute inset-0"></div> |
| <div class="relative z-10 text-center p-8 bg-black/30 backdrop-blur-sm rounded-xl shadow-2xl border border-white/10"> |
| |
| |
| <img src="LOGO.png" |
| alt="3D Animation Hub Logo" |
| class="h-56 lg:h-64 mx-auto mb-8 drop-shadow-lg" |
| onerror="this.onerror=null; this.src='https://placehold.co/300x100/3e2b7a/ffffff?text=LOGO';" /> |
| |
| <p class="mt-4 text-gray-200 text-lg font-medium drop-shadow-md mb-8 max-w-sm mx-auto"> |
| Select your character and animation. |
| </p> |
| <button id="enter-viewer-btn" class="p-4 lg:p-6 rounded-full bg-accent hover:bg-blue-600 text-white shadow-2xl transition-all duration-300 transform hover:scale-105 active:scale-95 focus:outline-none focus:ring-4 focus:ring-accent/50 group"> |
| |
| <img src="chara_dot_17.gif" alt="3D Viewer Icon" class="h-10 w-10 lg:h-12 lg:w-12 group-hover:animate-pulse transition duration-300 rounded-full" onerror="this.onerror=null; this.src='https://placehold.co/50x50/ff7f50/ffffff?text=3D';" /> |
| <span class="block mt-2 text-sm font-semibold">Enter Viewer</span> |
| </button> |
| </div> |
| </div> |
|
|
| |
| <div id="viewer-content" class="h-full w-full hidden flex flex-col"> |
| |
| <div class="lg:hidden p-3 bg-secondary-bg flex items-center justify-between shadow-lg flex-shrink-0 order-1"> |
| <h1 class="text-xl font-bold text-accent">3D Player</h1> |
| |
| <button id="burger-menu-btn" class="p-2 rounded-md hover:bg-gray-600 transition duration-150"> |
| <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-gray-100" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"> |
| <path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16" /> |
| </svg> |
| </button> |
| </div> |
|
|
| <div id="app-container" class="flex flex-col lg:flex-row flex-grow h-full overflow-hidden"> |
| |
| <div id="character-selector" class="p-4 bg-secondary-bg shadow-lg w-full flex-shrink-0 |
| order-3 lg:order-1 lg:w-40 h-auto lg:h-full |
| flex flex-row lg:flex-col space-x-4 lg:space-x-0 lg:space-y-4 |
| overflow-x-auto lg:overflow-x-hidden lg:overflow-y-auto border-t lg:border-t-0 lg:border-r border-gray-700"> |
| </div> |
|
|
| <div id="canvas-container" class="flex-grow order-2 relative flex flex-col h-full"> |
| |
| |
| |
| <div id="timeline-controls" class="absolute bottom-0 left-0 right-0 p-4 bg-secondary-bg/90 backdrop-blur-sm shadow-xl flex items-center space-x-4 border-t border-gray-700 flex-shrink-0"> |
| |
| |
| <button id="play-pause-btn" class="p-2 rounded-full bg-accent hover:bg-blue-600 text-white transition-colors duration-150 flex-shrink-0" title="Play / Pause"> |
| |
| <svg id="play-icon" xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 hidden" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"> |
| <path stroke-linecap="round" stroke-linejoin="round" d="M14.752 11.168l-3.197 2.132A1 1 0 0110 13.134V8.866a1 1 0 011.555-.832l3.197 2.132a1 1 0 010 1.664z" /> |
| <circle cx="12" cy="12" r="10" /> |
| </svg> |
| |
| <svg id="pause-icon" xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"> |
| <path stroke-linecap="round" stroke-linejoin="round" d="M10 9v6m4-6v6m7-3a9 9 0 11-18 0 9 9 0 0118 0z" /> |
| </svg> |
| </button> |
|
|
| |
| <input type="range" id="time-scrubber" min="0" max="1" value="0" step="0.01" class="flex-grow"> |
| |
| |
| <div class="text-sm font-mono text-gray-300 w-24 text-right flex-shrink-0"> |
| <span id="current-time">0.00</span> / <span id="duration-time">0.00</span> s |
| </div> |
| </div> |
| </div> |
| |
| <div id="desktop-controls-panel" class="p-6 bg-secondary-bg shadow-lg lg:w-80 w-full flex-shrink-0 lg:block hidden lg:order-3"> |
| </div> |
|
|
| </div> |
| |
| |
| <div id="mobile-controls-drawer" class="fixed top-0 right-0 w-3/4 h-full bg-secondary-bg shadow-2xl overflow-y-auto lg:hidden"> |
| <div class="p-6"> |
| <div class="flex justify-between items-center mb-6 border-b border-gray-600 pb-3"> |
| <h2 class="text-xl font-bold text-accent">Animation Options</h2> |
| <button id="close-menu-btn" class="text-gray-400 hover:text-white transition duration-150"> |
| <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"> |
| <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /> |
| </svg> |
| </button> |
| </div> |
| |
| <div id="controls-content"> |
| <p class="text-sm mb-6 text-gray-300"> |
| Set animation, looping, and rendering options here. |
| </p> |
|
|
| <div class="mb-4"> |
| <label for="animation-select" class="block text-sm font-medium mb-2">Select Animation:</label> |
| <select id="animation-select" |
| class="w-full p-2 border border-gray-600 bg-gray-700 text-white rounded-md focus:ring-accent focus:border-accent shadow-inner transition duration-150 ease-in-out" |
| disabled> |
| <option value="" disabled selected>Loading...</option> |
| </select> |
| </div> |
| |
| <div class="mb-6 flex items-center justify-between p-3 bg-gray-700 rounded-md"> |
| <label for="freeze-toggle" class="text-sm font-medium">Play Once (No Loop)</label> |
| <input type="checkbox" id="freeze-toggle" class="h-4 w-4 text-accent border-gray-600 rounded focus:ring-accent bg-gray-800"> |
| </div> |
| |
| <div class="mb-6 flex items-center justify-between p-3 bg-gray-700 rounded-md"> |
| <label for="cel-shade-toggle" class="text-sm font-medium">Anime/Cel Shade</label> |
| <input type="checkbox" id="cel-shade-toggle" class="h-4 w-4 text-accent border-gray-600 rounded focus:ring-accent bg-gray-800"> |
| </div> |
| |
| <div class="space-y-4 mb-6"> |
| <button id="reset-camera-btn" class="w-full bg-accent hover:bg-blue-600 text-white font-bold py-2 px-4 rounded-md transition duration-150 shadow-lg"> |
| Reset Camera View |
| </button> |
| </div> |
|
|
| <div id="loading-indicator" class="mt-4 p-3 bg-blue-900/50 text-accent rounded-lg flex items-center space-x-2"> |
| <svg class="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"> |
| <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> |
| <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path> |
| </svg> |
| <span>Loading 3D model...</span> |
| </div> |
|
|
| <div class="mt-8 pt-4 border-t border-gray-600"> |
| <p class="text-xs text-gray-400" id="model-credit">Model: model.glb (Your Custom Model)</p> |
| </div> |
| </div> |
| |
| </div> |
| </div> |
|
|
| <div id="mobile-menu-backdrop" class="fixed inset-0 bg-black/50 hidden z-40 lg:hidden"></div> |
| |
| |
| <div id="action-buttons-container"> |
| |
| |
| <button id="home-btn" onclick="goToHome()" class="bg-accent hover:bg-blue-600 text-white p-3 rounded-full shadow-xl transition-all duration-300 transform hover:scale-105"> |
| <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"> |
| <path stroke-linecap="round" stroke-linejoin="round" d="M3 12l9-9 9 9M5 10v7a2 2 0 002 2h10a2 2 0 002-2v-7"/> |
| </svg> |
| </button> |
|
|
| |
| <button id="help-btn" onclick="showHelpModal()" class="bg-accent hover:bg-blue-600 text-white p-3 rounded-full shadow-xl transition-all duration-300 transform hover:scale-105"> |
| <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"> |
| <path stroke-linecap="round" stroke-linejoin="round" d="M8.228 9c0-.424.43-.808 1.054-.925l.848-.15c.624-.117 1.054-.501 1.054-.925v-1c0-.424-.43-.808-1.054-.925l-.848-.15c-.624-.117-1.054-.501-1.054-.925v-1c0-.424.43-.808 1.054-.925l-.848-.15c-.624-.117-1.054-.501-1.054-.925zM12 20v-4" /> |
| </svg> |
| </button> |
| |
| </div> |
|
|
|
|
| <div id="help-modal-overlay" class="fixed inset-0 bg-black/70 flex items-center justify-center z-[100] transition-opacity duration-300 opacity-0 pointer-events-none"> |
| <div id="help-modal-content" class="bg-secondary-bg rounded-xl shadow-2xl p-6 m-4 w-full max-w-lg transform translate-y-[-50px] transition-transform duration-300"> |
| <div class="flex justify-between items-center border-b border-gray-600 pb-3 mb-4"> |
| <h3 class="text-xl font-bold text-accent">How to Use the Viewer</h3> |
| <button onclick="hideHelpModal()" class="text-gray-400 hover:text-white transition duration-150"> |
| <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"> |
| <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /> |
| </svg> |
| </button> |
| </div> |
| <div class="space-y-4 text-gray-300"> |
| <div> |
| <h4 class="font-semibold text-white mb-2">Desktop (PC/Mac)</h4> |
| <ul class="list-disc list-inside space-y-1 ml-4 text-sm"> |
| <li>**Rotate Camera:** Click and drag the model area.</li> |
| <li>**Pan Camera:** Right-click and drag the model area.</li> |
| <li>**Zoom In/Out:** Use the mouse scroll wheel.</li> |
| <li>**Change Character:** Click the icons on the left panel.</li> |
| <li>**Change Animation/Shading:** Use the controls on the right panel.</li> |
| <li>**Pause/Scrub:** Use the controls at the bottom of the viewer.</li> |
| </ul> |
| </div> |
| <div> |
| <h4 class="font-semibold text-white mb-2">Mobile (iPhone/Android)</h4> |
| <ul class="list-disc list-inside space-y-1 ml-4 text-sm"> |
| <li>**Rotate Camera:** Drag a single finger across the screen.</li> |
| <li>**Pan Camera:** Drag two fingers across the screen.</li> |
| <li>**Zoom In/Out:** Pinch the screen with two fingers.</li> |
| <li>**Change Character:** Scroll the character bar at the bottom and tap an icon.</li> |
| <li>**Controls:** Tap the **Menu (☰)** icon to access the controls.</li> |
| <li>**Pause/Scrub:** Use the controls at the bottom of the viewer.</li> |
| </ul> |
| </div> |
| </div> |
| </div> |
| </div> |
| |
| </div> |
| </div> |
| |
| <script> |
| let scene, camera, renderer, controls; |
| let mixer; |
| let actions = {}; |
| let activeAction; |
| let currentModel = null; |
| const clock = new THREE.Clock(); |
| |
| let modelTargetCenter = new THREE.Vector3(0, 1.0, 0); |
| let initialCameraPosition = new THREE.Vector3(0, 1.5, 3); |
| |
| const TARGET_HEIGHT = 2; |
| let freezeLastFrame = false; |
| let isCelShadeEnabled = false; |
| let defaultMaterials = new Map(); |
| |
| let isAnimationPlaying = true; |
| let animationDuration = 0; |
| |
| const CHARACTER_DATA = { |
| 'model': { |
| name: 'Character 1 (model)', |
| url: 'model.glb', |
| image: 'ch_fullscreen_101101.png', |
| num: 1 |
| }, |
| 'model1': { |
| name: 'Character 2 (model1)', |
| url: 'model1.glb', |
| image: 'ch_fullscreen_102901.png', |
| num: 2 |
| } |
| }; |
| |
| const imageMap = [ |
| 'ch_fullscreen_101401.png', |
| 'ch_fullscreen_103001.png', |
| 'ch_fullscreen_100101.png', |
| 'ch_fullscreen_101501.png', |
| 'ch_fullscreen_100201.png', |
| 'ch_fullscreen_100701.png', |
| 'ch_fullscreen_100901.png', |
| 'ch_fullscreen_100601.png', |
| 'ch_fullscreen_101201.png', |
| 'tc_fullscreen_5103103.png', |
| 'ch_fullscreen_100501.png', |
| 'ch_fullscreen_100301.png', |
| 'ch_fullscreen_100801.png', |
| 'ch_fullscreen_101001.png', |
| 'ch_fullscreen_101301.png', |
| 'ch_fullscreen_100401.png', |
| 'ch_fullscreen_103201.png', |
| 'ch_fullscreen_103301.png', |
| 'ch_fullscreen_103401.png', |
| ]; |
| |
| for (let i = 2; i <= 20; i++) { |
| const modelKey = `model${i}`; |
| const charNumber = i + 1; |
| |
| let imageUrl = imageMap[i - 2]; |
| |
| CHARACTER_DATA[modelKey] = { |
| name: `Character ${charNumber} (${modelKey})`, |
| url: `${modelKey}.glb`, |
| image: imageUrl, |
| num: charNumber |
| }; |
| } |
| |
| let currentCharacterId = 'model'; |
| |
| const container = document.getElementById('canvas-container'); |
| const selectorContainer = document.getElementById('character-selector'); |
| |
| const mobileDrawer = document.getElementById('mobile-controls-drawer'); |
| const desktopPanel = document.getElementById('desktop-controls-panel'); |
| const burgerMenuBtn = document.getElementById('burger-menu-btn'); |
| const closeMenuBtn = document.getElementById('close-menu-btn'); |
| const menuBackdrop = document.getElementById('mobile-menu-backdrop'); |
| const helpModalOverlay = document.getElementById('help-modal-overlay'); |
| |
| const homeScreen = document.getElementById('home-screen'); |
| const viewerContent = document.getElementById('viewer-content'); |
| const enterViewerBtn = document.getElementById('enter-viewer-btn'); |
| |
| |
| const selectElement = document.getElementById('animation-select'); |
| const loadingIndicator = document.getElementById('loading-indicator'); |
| const freezeToggle = document.getElementById('freeze-toggle'); |
| const celShadeToggle = document.getElementById('cel-shade-toggle'); |
| const modelCredit = document.getElementById('model-credit'); |
| const resetCameraButton = document.getElementById('reset-camera-btn'); |
| |
| |
| const playPauseBtn = document.getElementById('play-pause-btn'); |
| const playIcon = document.getElementById('play-icon'); |
| const pauseIcon = document.getElementById('pause-icon'); |
| const timeScrubber = document.getElementById('time-scrubber'); |
| const currentTimeDisplay = document.getElementById('current-time'); |
| const durationDisplay = document.getElementById('duration-time'); |
| |
| |
| |
| |
| |
| |
| function formatTime(time) { |
| return time.toFixed(2); |
| } |
| |
| function showHelpModal() { |
| helpModalOverlay.classList.add('visible'); |
| document.body.style.overflow = 'hidden'; |
| } |
| |
| function hideHelpModal() { |
| helpModalOverlay.classList.remove('visible'); |
| document.body.style.overflow = 'hidden'; |
| } |
| |
| function goToHome() { |
| |
| viewerContent.classList.add('hidden'); |
| |
| homeScreen.classList.remove('hidden'); |
| } |
| |
| function unloadModel() { |
| if (currentModel) { |
| scene.remove(currentModel); |
| currentModel = null; |
| } |
| mixer = null; |
| actions = {}; |
| activeAction = null; |
| defaultMaterials.clear(); |
| animationDuration = 0; |
| isAnimationPlaying = true; |
| |
| selectElement.innerHTML = '<option value="" disabled selected>Loading...</option>'; |
| selectElement.disabled = true; |
| modelCredit.textContent = 'Model: Loading...'; |
| desktopPanel.querySelector('#animation-select').innerHTML = '<option value="" disabled selected>Loading...</option>'; |
| desktopPanel.querySelector('#animation-select').disabled = true; |
| |
| |
| updateTimelineUI(0); |
| } |
| |
| function resetCamera() { |
| camera.position.copy(initialCameraPosition); |
| controls.target.copy(modelTargetCenter); |
| controls.update(); |
| } |
| |
| |
| |
| |
| function togglePlayPause() { |
| isAnimationPlaying = !isAnimationPlaying; |
| |
| if (activeAction) { |
| if (isAnimationPlaying) { |
| |
| if (activeAction.paused) { |
| activeAction.stop().play(); |
| } else { |
| |
| activeAction.play(); |
| } |
| playIcon.classList.add('hidden'); |
| pauseIcon.classList.remove('hidden'); |
| } else { |
| |
| activeAction.pause(); |
| playIcon.classList.remove('hidden'); |
| pauseIcon.classList.add('hidden'); |
| } |
| } else { |
| |
| if (isAnimationPlaying) { |
| playIcon.classList.add('hidden'); |
| pauseIcon.classList.remove('hidden'); |
| } else { |
| playIcon.classList.remove('hidden'); |
| pauseIcon.classList.add('hidden'); |
| } |
| } |
| } |
| |
| |
| |
| |
| |
| function updateTimelineUI(currentTime) { |
| currentTimeDisplay.textContent = formatTime(currentTime); |
| durationDisplay.textContent = formatTime(animationDuration); |
| |
| if (!timeScrubber.isDragging) { |
| |
| timeScrubber.max = animationDuration; |
| timeScrubber.value = currentTime; |
| } |
| } |
| |
| |
| |
| |
| |
| function onScrubberInput(event) { |
| if (mixer && activeAction) { |
| const newTime = parseFloat(event.target.value); |
| |
| |
| if(isAnimationPlaying) { |
| isAnimationPlaying = false; |
| playIcon.classList.remove('hidden'); |
| pauseIcon.classList.add('hidden'); |
| } |
| |
| mixer.setTime(newTime); |
| updateTimelineUI(newTime); |
| timeScrubber.isDragging = true; |
| |
| |
| if (freezeLastFrame && newTime < animationDuration) { |
| if (activeAction.paused) { |
| |
| activeAction.play(); |
| activeAction.paused = false; |
| } |
| } |
| } |
| } |
| |
| |
| |
| |
| |
| function onScrubberChange(event) { |
| timeScrubber.isDragging = false; |
| } |
| |
| |
| function initScene() { |
| |
| if (renderer) return; |
| |
| scene = new THREE.Scene(); |
| scene.background = new THREE.Color(0x3e2b7a); |
| |
| const ambientLight = new THREE.AmbientLight(0x505050); |
| scene.add(ambientLight); |
| |
| const directionalLight = new THREE.DirectionalLight(0xffffff, 1.0); |
| directionalLight.position.set(5, 10, 7.5); |
| scene.add(directionalLight); |
| |
| |
| const timelineHeight = 64; |
| const canvasHeight = container.clientHeight - timelineHeight; |
| const aspect = container.clientWidth / canvasHeight; |
| |
| camera = new THREE.PerspectiveCamera(50, aspect, 0.001, 100); |
| camera.position.copy(initialCameraPosition); |
| |
| renderer = new THREE.WebGLRenderer({ antialias: true }); |
| renderer.setPixelRatio(window.devicePixelRatio); |
| renderer.setSize(container.clientWidth, canvasHeight); |
| |
| |
| renderer.domElement.style.height = `${canvasHeight}px`; |
| renderer.domElement.style.width = '100%'; |
| |
| container.prepend(renderer.domElement); |
| |
| controls = new THREE.OrbitControls(camera, renderer.domElement); |
| controls.target.copy(modelTargetCenter); |
| controls.minDistance = 0.5; |
| controls.update(); |
| |
| window.addEventListener('resize', onWindowResize); |
| |
| burgerMenuBtn.addEventListener('click', () => toggleMobileMenu(true)); |
| closeMenuBtn.addEventListener('click', () => toggleMobileMenu(false)); |
| menuBackdrop.addEventListener('click', () => toggleMobileMenu(false)); |
| |
| |
| const controlsContentClone = document.getElementById('controls-content').cloneNode(true); |
| controlsContentClone.id = 'controls-content-desktop'; |
| desktopPanel.innerHTML = controlsContentClone.innerHTML; |
| |
| |
| const desktopSelect = desktopPanel.querySelector('#animation-select'); |
| const desktopFreeze = desktopPanel.querySelector('#freeze-toggle'); |
| const desktopCelShade = desktopPanel.querySelector('#cel-shade-toggle'); |
| const desktopReset = desktopPanel.querySelector('#reset-camera-btn'); |
| |
| |
| selectElement.addEventListener('change', onAnimationChange); |
| desktopSelect.addEventListener('change', onAnimationChange); |
| |
| resetCameraButton.addEventListener('click', resetCamera); |
| desktopReset.addEventListener('click', resetCamera); |
| |
| freezeToggle.addEventListener('change', (e) => updateAnimationLoop(e.target)); |
| desktopFreeze.addEventListener('change', (e) => updateAnimationLoop(e.target)); |
| |
| celShadeToggle.addEventListener('change', (e) => toggleCelShade(e.target)); |
| desktopCelShade.addEventListener('change', (e) => toggleCelShade(e.target)); |
| |
| |
| playPauseBtn.addEventListener('click', togglePlayPause); |
| timeScrubber.addEventListener('input', onScrubberInput); |
| timeScrubber.addEventListener('change', onScrubberChange); |
| } |
| |
| function toggleMobileMenu(open) { |
| if (open) { |
| mobileDrawer.classList.add('open'); |
| menuBackdrop.classList.remove('hidden'); |
| } else { |
| mobileDrawer.classList.remove('open'); |
| menuBackdrop.classList.add('hidden'); |
| } |
| } |
| |
| function loadModel(url) { |
| unloadModel(); |
| loadingIndicator.classList.remove('hidden'); |
| desktopPanel.querySelector('#loading-indicator')?.classList.remove('hidden'); |
| |
| |
| const loader = new THREE.GLTFLoader(); |
| const currentCharacter = CHARACTER_DATA[currentCharacterId]; |
| |
| loader.load(url, function (gltf) { |
| const model = gltf.scene; |
| currentModel = model; |
| |
| defaultMaterials.clear(); |
| |
| model.traverse((child) => { |
| if (child.isMesh) { |
| child.frustumCulled = false; |
| |
| const defaultMat = new THREE.MeshBasicMaterial(); |
| |
| if (child.material.map) { |
| defaultMat.map = child.material.map; |
| } |
| if (child.material.color) { |
| defaultMat.color.copy(child.material.color); |
| } |
| |
| defaultMat.side = THREE.DoubleSide; |
| |
| if (child.material.skinning === true) { |
| defaultMat.skinning = true; |
| } |
| |
| defaultMaterials.set(child.uuid, defaultMat); |
| |
| if (isCelShadeEnabled) { |
| const celMat = createCelShadeMaterial(defaultMat); |
| child.material = celMat; |
| } else { |
| child.material = defaultMat; |
| } |
| } |
| }); |
| |
| model.updateMatrixWorld(true); |
| let box = new THREE.Box3().setFromObject(model); |
| let size = new THREE.Vector3(); |
| box.getSize(size); |
| |
| if (size.y > 0) { |
| const scaleFactor = TARGET_HEIGHT / size.y; |
| model.scale.set(scaleFactor, scaleFactor, scaleFactor); |
| } |
| |
| scene.add(model); |
| |
| model.updateMatrixWorld(true); |
| box.setFromObject(model); |
| const center = new THREE.Vector3(); |
| box.getCenter(center); |
| box.getSize(size); |
| |
| const yShift = -box.min.y; |
| model.position.y += yShift; |
| |
| model.updateMatrixWorld(true); |
| box.setFromObject(model); |
| box.getCenter(center); |
| box.getSize(size); |
| |
| modelTargetCenter.copy(center); |
| |
| const maxDim = Math.max(size.x, size.y, size.z); |
| const initialDistance = maxDim * 1.5; |
| |
| controls.minDistance = maxDim * 0.001; |
| initialCameraPosition.set(center.x, center.y + (maxDim * 0.2), center.z + initialDistance); |
| |
| camera.position.copy(initialCameraPosition); |
| controls.target.copy(modelTargetCenter); |
| |
| camera.updateProjectionMatrix(); |
| controls.update(); |
| |
| mixer = new THREE.AnimationMixer(model); |
| |
| |
| mixer.addEventListener( 'finished', function ( e ) { |
| if (e.action.getClip().duration === animationDuration && freezeLastFrame) { |
| isAnimationPlaying = false; |
| playIcon.classList.remove('hidden'); |
| pauseIcon.classList.add('hidden'); |
| } |
| }); |
| |
| const desktopSelect = desktopPanel.querySelector('#animation-select'); |
| |
| if (gltf.animations && gltf.animations.length > 0) { |
| selectElement.innerHTML = ''; |
| desktopSelect.innerHTML = ''; |
| |
| gltf.animations.forEach((clip) => { |
| const action = mixer.clipAction(clip); |
| actions[clip.name] = action; |
| |
| const option = document.createElement('option'); |
| option.value = clip.name; |
| option.textContent = clip.name.replace(/_/g, ' '); |
| |
| selectElement.appendChild(option.cloneNode(true)); |
| desktopSelect.appendChild(option.cloneNode(true)); |
| }); |
| |
| let initialClipName = gltf.animations[0].name; |
| const idleClip = gltf.animations.find(clip => clip.name.toLowerCase().includes('idle')); |
| |
| if (idleClip) { |
| initialClipName = idleClip.name; |
| } |
| |
| selectElement.value = initialClipName; |
| desktopSelect.value = initialClipName; |
| |
| activeAction = actions[initialClipName]; |
| |
| |
| animationDuration = activeAction.getClip().duration; |
| timeScrubber.max = animationDuration; |
| updateTimelineUI(0); |
| |
| |
| isAnimationPlaying = true; |
| |
| |
| |
| playIcon.classList.add('hidden'); |
| pauseIcon.classList.remove('hidden'); |
| |
| updateAnimationLoop(freezeToggle); |
| activeAction.play(); |
| |
| loadingIndicator.classList.add('hidden'); |
| desktopPanel.querySelector('#loading-indicator')?.classList.add('hidden'); |
| selectElement.disabled = false; |
| desktopSelect.disabled = false; |
| } else { |
| loadingIndicator.innerHTML = `Model loaded, but no animations found. (${currentCharacter.url})`; |
| desktopPanel.querySelector('#loading-indicator').innerHTML = `Model loaded, but no animations found. (${currentCharacter.url})`; |
| |
| selectElement.innerHTML = '<option value="" disabled selected>No Animations</option>'; |
| desktopSelect.innerHTML = '<option value="" disabled selected>No Animations</option>'; |
| |
| animationDuration = 0; |
| timeScrubber.max = 0; |
| updateTimelineUI(0); |
| } |
| |
| modelCredit.textContent = `Model: ${currentCharacter.name} (${currentCharacter.url})`; |
| desktopPanel.querySelector('#model-credit').textContent = `Model: ${currentCharacter.name} (${currentCharacter.url})`; |
| |
| }, undefined, function (error) { |
| console.error('An error occurred while loading the model:', error); |
| loadingIndicator.innerHTML = `Error loading model (${currentCharacter.name}). The file (${currentCharacter.url}) might be missing.`; |
| desktopPanel.querySelector('#loading-indicator').innerHTML = `Error loading model (${currentCharacter.name}). The file (${currentCharacter.url}) might be missing.`; |
| modelCredit.textContent = `Model: ${currentCharacter.name} (Load Failed)`; |
| desktopPanel.querySelector('#model-credit').textContent = `Model: ${currentCharacter.name} (Load Failed)`; |
| |
| animationDuration = 0; |
| timeScrubber.max = 0; |
| updateTimelineUI(0); |
| }); |
| } |
| |
| function createCelShadeMaterial(baseMaterial) { |
| const celMat = new THREE.MeshToonMaterial(); |
| if (baseMaterial.map) { |
| celMat.map = baseMaterial.map; |
| } |
| celMat.color.copy(baseMaterial.color); |
| celMat.side = THREE.DoubleSide; |
| if (baseMaterial.skinning === true) { |
| celMat.skinning = true; |
| } |
| return celMat; |
| } |
| |
| function toggleCelShade(sourceElement) { |
| isCelShadeEnabled = sourceElement.checked; |
| |
| const desktopToggle = desktopPanel.querySelector('#cel-shade-toggle'); |
| |
| if (sourceElement.id === 'cel-shade-toggle') { |
| celShadeToggle.checked = isCelShadeEnabled; |
| desktopToggle.checked = isCelShadeEnabled; |
| } else if (sourceElement.closest('#desktop-controls-panel')) { |
| celShadeToggle.checked = isCelShadeEnabled; |
| desktopToggle.checked = isCelShadeEnabled; |
| } |
| |
| updateCelShadeToggleState(); |
| } |
| |
| function updateCelShadeToggleState() { |
| if (!currentModel) return; |
| |
| currentModel.traverse((child) => { |
| if (child.isMesh) { |
| const defaultMat = defaultMaterials.get(child.uuid); |
| |
| if (defaultMat) { |
| if (isCelShadeEnabled) { |
| child.material = createCelShadeMaterial(defaultMat); |
| } else { |
| child.material = defaultMat; |
| } |
| } |
| } |
| }); |
| } |
| |
| function loadCharacter(id) { |
| currentCharacterId = id; |
| const character = CHARACTER_DATA[id]; |
| |
| document.querySelectorAll('.character-icon-container').forEach(icon => { |
| icon.classList.remove('active'); |
| }); |
| const activeIcon = document.getElementById(`icon-${id}`); |
| if(activeIcon) activeIcon.classList.add('active'); |
| |
| loadModel(character.url); |
| } |
| |
| function createCharacterSelectors() { |
| |
| if (selectorContainer.children.length > 0) return; |
| |
| Object.keys(CHARACTER_DATA).forEach(id => { |
| const char = CHARACTER_DATA[id]; |
| |
| const iconContainer = document.createElement('div'); |
| iconContainer.id = `icon-${id}`; |
| iconContainer.className = 'character-icon-container'; |
| iconContainer.title = char.name; |
| |
| const iconImage = document.createElement('img'); |
| iconImage.className = 'character-icon'; |
| iconImage.src = char.image; |
| iconImage.alt = char.name; |
| |
| iconImage.onerror = function() { |
| const fallbackText = `C${char.num}`; |
| this.src = `https://placehold.co/80x80/555555/dddddd?text=${fallbackText}`; |
| this.onerror = null; |
| }; |
| |
| if (id === currentCharacterId) { |
| iconContainer.classList.add('active'); |
| } |
| |
| iconContainer.addEventListener('click', () => loadCharacter(id)); |
| iconContainer.appendChild(iconImage); |
| selectorContainer.appendChild(iconContainer); |
| }); |
| } |
| |
| function updateAnimationLoop(sourceElement) { |
| |
| const wasFrozen = freezeLastFrame; |
| freezeLastFrame = sourceElement.checked; |
| |
| const desktopFreeze = desktopPanel.querySelector('#freeze-toggle'); |
| |
| if (sourceElement.id === 'freeze-toggle') { |
| freezeToggle.checked = freezeLastFrame; |
| desktopFreeze.checked = freezeLastFrame; |
| } else if (sourceElement.closest('#desktop-controls-panel')) { |
| freezeToggle.checked = freezeLastFrame; |
| desktopFreeze.checked = freezeLastFrame; |
| } |
| |
| const loopMode = freezeLastFrame ? THREE.LoopOnce : THREE.LoopRepeat; |
| const clamp = freezeLastFrame; |
| |
| Object.values(actions).forEach(action => { |
| action.setLoop(loopMode); |
| action.clampWhenFinished = clamp; |
| }); |
| |
| |
| |
| if (activeAction) { |
| |
| if (wasFrozen !== freezeLastFrame || activeAction.paused) { |
| activeAction.stop().play(); |
| isAnimationPlaying = true; |
| playIcon.classList.add('hidden'); |
| pauseIcon.classList.remove('hidden'); |
| } |
| } |
| } |
| |
| function onAnimationChange(event) { |
| const newClipName = event.target.value; |
| |
| selectElement.value = newClipName; |
| desktopPanel.querySelector('#animation-select').value = newClipName; |
| |
| const newAction = actions[newClipName]; |
| |
| if (newAction && newAction !== activeAction) { |
| const previousAction = activeAction; |
| activeAction = newAction; |
| |
| |
| animationDuration = activeAction.getClip().duration; |
| timeScrubber.max = animationDuration; |
| updateTimelineUI(0); |
| |
| |
| updateAnimationLoop(freezeToggle); |
| |
| previousAction.fadeOut(0.5); |
| |
| activeAction |
| .reset() |
| .setEffectiveTimeScale(1) |
| .setEffectiveWeight(1) |
| .fadeIn(0.5) |
| .play(); |
| |
| isAnimationPlaying = true; |
| playIcon.classList.add('hidden'); |
| pauseIcon.classList.remove('hidden'); |
| } |
| toggleMobileMenu(false); |
| } |
| |
| function animate() { |
| requestAnimationFrame(animate); |
| if (!renderer) return; |
| |
| const delta = clock.getDelta(); |
| |
| |
| if (mixer && isAnimationPlaying) { |
| mixer.update(delta); |
| } |
| |
| |
| if (activeAction) { |
| let currentTime = activeAction.time; |
| |
| |
| if (freezeLastFrame && activeAction.paused && currentTime >= animationDuration - 0.001) { |
| currentTime = animationDuration; |
| } |
| |
| updateTimelineUI(currentTime); |
| } |
| |
| |
| controls.update(); |
| renderer.render(scene, camera); |
| } |
| |
| function onWindowResize() { |
| if (!renderer) return; |
| |
| const timelineHeight = 64; |
| const width = container.clientWidth; |
| const height = container.clientHeight - timelineHeight; |
| |
| camera.aspect = width / height; |
| camera.updateProjectionMatrix(); |
| |
| renderer.setSize(width, height); |
| renderer.domElement.style.height = `${height}px`; |
| renderer.domElement.style.width = '100%'; |
| } |
| |
| function enterViewer() { |
| |
| homeScreen.classList.add('hidden'); |
| |
| viewerContent.classList.remove('hidden'); |
| document.body.style.overflow = 'hidden'; |
| |
| try { |
| |
| if (!renderer) { |
| initScene(); |
| createCharacterSelectors(); |
| loadCharacter(currentCharacterId); |
| animate(); |
| } |
| onWindowResize(); |
| } catch (e) { |
| console.error("Initialization failed:", e); |
| loadingIndicator.innerHTML = 'Critical Error: Check console for details.'; |
| } |
| } |
| |
| window.onload = function () { |
| |
| homeScreen.classList.remove('hidden'); |
| viewerContent.classList.add('hidden'); |
| document.body.style.overflow = 'hidden'; |
| |
| enterViewerBtn.addEventListener('click', enterViewer); |
| }; |
| </script> |
| </body> |
| </html> |