tribe3D / index.html
kokixdx's picture
Update index.html
c77cb26 verified
<!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 Styles --- */
#home-screen {
background-image: url('bckg_home.png'); /* Placeholder for your background image */
background-size: cover;
background-position: center;
}
.purple-overlay {
/* Purple overlay at 50% opacity (Tailwind indigo-500) */
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;
}
/* Container for bottom-right buttons to manage spacing */
#action-buttons-container {
@apply fixed bottom-4 right-4 flex space-x-3 z-50;
}
/* Custom styling for the timeline slider */
input[type="range"] {
-webkit-appearance: none;
width: 100%;
height: 8px;
background: #4b5563; /* gray-600 */
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; /* accent */
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; /* accent */
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">
<!-- HOME SCREEN -->
<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">
<!-- LOGO IMAGE REPLACING TEXT H1 -->
<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">
<!-- Icon for the viewer -->
<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>
<!-- VIEWER CONTENT -->
<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">
<!-- 3D Canvas will be placed here by JS -->
<!-- Animation Timeline Controls -->
<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">
<!-- Play/Pause Button -->
<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">
<!-- Play Icon (Default) -->
<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>
<!-- Pause Icon (Hidden by default) -->
<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>
<!-- Time Scrubber (Input Range) -->
<input type="range" id="time-scrubber" min="0" max="1" value="0" step="0.01" class="flex-grow">
<!-- Time Display (Current / Total) -->
<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>
<!-- Mobile Drawer and Backdrop (Hidden on Desktop) -->
<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>
<!-- Action Buttons Container (Home/Help) -->
<div id="action-buttons-container">
<!-- Home Button -->
<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>
<!-- Help Button (was already here) -->
<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; // Controls LoopOnce/LoopRepeat for animation
let isCelShadeEnabled = false;
let defaultMaterials = new Map();
let isAnimationPlaying = true; // New state for Play/Pause
let animationDuration = 0; // Total duration of the current animation
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');
// New Timeline Control References
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');
/**
* Utility to format time in seconds to two decimal places.
* @param {number} time
* @returns {string}
*/
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'; // Keep body overflow hidden to prevent 3D viewer scrolling
}
function goToHome() {
// Hide Viewer Content
viewerContent.classList.add('hidden');
// Show Home Screen
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;
// Reset timeline UI
updateTimelineUI(0);
}
function resetCamera() {
camera.position.copy(initialCameraPosition);
controls.target.copy(modelTargetCenter);
controls.update();
}
/**
* Toggles the play/pause state of the animation.
*/
function togglePlayPause() {
isAnimationPlaying = !isAnimationPlaying;
if (activeAction) {
if (isAnimationPlaying) {
// FIX: If the action is paused (which happens when LoopOnce finishes), restart it.
if (activeAction.paused) {
activeAction.stop().play();
} else {
// Otherwise, just resume.
activeAction.play();
}
playIcon.classList.add('hidden');
pauseIcon.classList.remove('hidden');
} else {
// When pausing:
activeAction.pause();
playIcon.classList.remove('hidden');
pauseIcon.classList.add('hidden');
}
} else {
// If no active action, just update UI
if (isAnimationPlaying) {
playIcon.classList.add('hidden');
pauseIcon.classList.remove('hidden');
} else {
playIcon.classList.remove('hidden');
pauseIcon.classList.add('hidden');
}
}
}
/**
* Updates the time display and scrubber based on current animation time.
* @param {number} currentTime
*/
function updateTimelineUI(currentTime) {
currentTimeDisplay.textContent = formatTime(currentTime);
durationDisplay.textContent = formatTime(animationDuration);
if (!timeScrubber.isDragging) {
// The scrubber value needs to be normalized (0 to duration)
timeScrubber.max = animationDuration;
timeScrubber.value = currentTime;
}
}
/**
* Handles scrubbing input. Updates the mixer time directly.
* @param {Event} event
*/
function onScrubberInput(event) {
if (mixer && activeAction) {
const newTime = parseFloat(event.target.value);
// Pause animation when scrubbing
if(isAnimationPlaying) {
isAnimationPlaying = false;
playIcon.classList.remove('hidden');
pauseIcon.classList.add('hidden');
}
mixer.setTime(newTime);
updateTimelineUI(newTime);
timeScrubber.isDragging = true;
// If animation is LoopOnce and we scrub back, resume playback
if (freezeLastFrame && newTime < animationDuration) {
if (activeAction.paused) {
// Use activeAction.play() to force resume playback without fade/reset
activeAction.play();
activeAction.paused = false; // Manually reset paused state
}
}
}
}
/**
* Handles scrubber release. Optional: Resume playback if it was playing before drag.
* @param {Event} event
*/
function onScrubberChange(event) {
timeScrubber.isDragging = false;
}
function initScene() {
// Check if scene is already initialized
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);
// Calculate height available for canvas (total container height - timeline height)
const timelineHeight = 64; // Approximate height of timeline-controls in px (p-4 + content)
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); // Set initial size
// Set canvas size based on container - timeline
renderer.domElement.style.height = `${canvasHeight}px`;
renderer.domElement.style.width = '100%';
container.prepend(renderer.domElement); // Add canvas before timeline controls
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));
// Populate the desktop panel with controls content
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');
// Event listeners
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));
// Timeline event listeners
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);
// Add event listener to mixer to handle LoopOnce finishing
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];
// Set initial duration and timeline values
animationDuration = activeAction.getClip().duration;
timeScrubber.max = animationDuration;
updateTimelineUI(0);
// Ensure play state is reflected correctly
isAnimationPlaying = true;
// Initial setting: call togglePlayPause to set initial icon state
// If we want it to start playing immediately, we don't call togglePlayPause()
// We just set the icons correctly
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');
// Ensure consistency between mobile and desktop toggles
if (sourceElement.id === 'cel-shade-toggle') {
celShadeToggle.checked = isCelShadeEnabled; // Update mobile
desktopToggle.checked = isCelShadeEnabled; // Update desktop
} else if (sourceElement.closest('#desktop-controls-panel')) {
celShadeToggle.checked = isCelShadeEnabled; // Update mobile
desktopToggle.checked = isCelShadeEnabled; // Update desktop
}
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() {
// Check if selectors are already created
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) {
// Store the current loop state before changing it
const wasFrozen = freezeLastFrame;
freezeLastFrame = sourceElement.checked;
const desktopFreeze = desktopPanel.querySelector('#freeze-toggle');
// Ensure consistency between mobile and desktop toggles
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;
});
// FIX: If the loop mode has changed, we must stop and play the action
// to ensure the new loop setting is applied immediately and the animation restarts.
if (activeAction) {
// Only restart if the state changed or if it was paused (finished LoopOnce)
if (wasFrozen !== freezeLastFrame || activeAction.paused) {
activeAction.stop().play();
isAnimationPlaying = true; // Ensure play state is true when loop setting changes
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;
// Update duration for the new clip
animationDuration = activeAction.getClip().duration;
timeScrubber.max = animationDuration;
updateTimelineUI(0); // Reset time display
// Ensure loop mode is set for the new action
updateAnimationLoop(freezeToggle);
previousAction.fadeOut(0.5);
activeAction
.reset()
.setEffectiveTimeScale(1)
.setEffectiveWeight(1)
.fadeIn(0.5)
.play();
isAnimationPlaying = true; // Ensure play state is true when changing animation
playIcon.classList.add('hidden');
pauseIcon.classList.remove('hidden');
}
toggleMobileMenu(false);
}
function animate() {
requestAnimationFrame(animate);
if (!renderer) return;
const delta = clock.getDelta();
// Only update mixer if animation is explicitly playing
if (mixer && isAnimationPlaying) {
mixer.update(delta);
}
// Update time display and scrubber based on the active action's current time
if (activeAction) {
let currentTime = activeAction.time;
// If animation is set to LoopOnce and has finished, time might not update, so we clamp it to duration
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() {
// Hide Home Screen
homeScreen.classList.add('hidden');
// Show Viewer Content
viewerContent.classList.remove('hidden');
document.body.style.overflow = 'hidden';
try {
// Initialize Three.js and load content
if (!renderer) {
initScene();
createCharacterSelectors();
loadCharacter(currentCharacterId);
animate(); // Start the render loop
}
onWindowResize(); // Ensure canvas is correctly sized
} catch (e) {
console.error("Initialization failed:", e);
loadingIndicator.innerHTML = 'Critical Error: Check console for details.';
}
}
window.onload = function () {
// Initial state: Show home screen, hide viewer content
homeScreen.classList.remove('hidden');
viewerContent.classList.add('hidden');
document.body.style.overflow = 'hidden';
enterViewerBtn.addEventListener('click', enterViewer);
};
</script>
</body>
</html>