Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>3D Character World</title> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r164/three.min.js"></script> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r164/examples/jsm/controls/OrbitControls.js"></script> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r164/examples/jsm/loaders/GLTFLoader.js"></script> | |
| <style> | |
| body { | |
| margin: 0; | |
| overflow: hidden; | |
| font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; | |
| background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%); | |
| } | |
| #canvas { | |
| display: block; | |
| } | |
| .transition-all { | |
| transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); | |
| } | |
| .character-card { | |
| background: linear-gradient(145deg, rgba(30, 41, 59, 0.9), rgba(15, 23, 42, 0.9)); | |
| backdrop-filter: blur(10px); | |
| border: 1px solid rgba(255, 255, 255, 0.1); | |
| box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); | |
| } | |
| .character-card:hover { | |
| transform: translateY(-8px) scale(1.02); | |
| box-shadow: 0 20px 40px rgba(0, 0, 0, 0.4); | |
| border-color: rgba(139, 92, 246, 0.5); | |
| } | |
| .dialog-box { | |
| background: rgba(15, 23, 42, 0.95); | |
| backdrop-filter: blur(20px); | |
| border: 1px solid rgba(255, 255, 255, 0.1); | |
| box-shadow: 0 25px 50px rgba(0, 0, 0, 0.5); | |
| } | |
| .character-preview { | |
| width: 100%; | |
| height: 200px; | |
| background: linear-gradient(145deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.02)); | |
| border-radius: 1rem; | |
| border: 1px solid rgba(255, 255, 255, 0.1); | |
| position: relative; | |
| overflow: hidden; | |
| } | |
| .character-preview::before { | |
| content: ''; | |
| position: absolute; | |
| top: 0; | |
| left: 0; | |
| right: 0; | |
| bottom: 0; | |
| background: linear-gradient(45deg, transparent, rgba(255, 255, 255, 0.05), transparent); | |
| opacity: 0; | |
| transition: opacity 0.3s ease; | |
| } | |
| .character-card:hover .character-preview::before { | |
| opacity: 1; | |
| } | |
| .glass-effect { | |
| background: rgba(255, 255, 255, 0.1); | |
| backdrop-filter: blur(10px); | |
| border: 1px solid rgba(255, 255, 255, 0.2); | |
| } | |
| .gradient-text { | |
| background: linear-gradient(135deg, #8b5cf6 0%, #3b82f6 100%); | |
| -webkit-background-clip: text; | |
| -webkit-text-fill-color: transparent; | |
| background-clip: text; | |
| } | |
| .floating-animation { | |
| animation: float 6s ease-in-out infinite; | |
| } | |
| @keyframes float { | |
| 0%, 100% { transform: translateY(0px); } | |
| 50% { transform: translateY(-10px); } | |
| } | |
| .pulse-glow { | |
| animation: pulse-glow 2s ease-in-out infinite alternate; | |
| } | |
| @keyframes pulse-glow { | |
| from { box-shadow: 0 0 20px rgba(139, 92, 246, 0.4); } | |
| to { box-shadow: 0 0 30px rgba(139, 92, 246, 0.8); } | |
| } | |
| </style> | |
| </head> | |
| <body class="bg-gray-900 text-white"> | |
| <!-- Welcome Screen --> | |
| <div id="welcome-screen" class="fixed inset-0 flex items-center justify-center bg-gradient-to-br from-slate-900 via-purple-900 to-slate-900 z-50 transition-all duration-500"> | |
| <div class="absolute inset-0 overflow-hidden"> | |
| <div class="absolute -top-1/2 -left-1/2 w-full h-full bg-gradient-conic from-transparent via-purple-500/10 to-transparent animate-spin-slow"></div> | |
| <div class="absolute -top-1/2 -right-1/2 w-full h-full bg-gradient-conic from-transparent via-blue-500/10 to-transparent animate-spin-slow" style="animation-delay: -3s;"></div> | |
| </div> | |
| <div class="text-center max-w-4xl p-12 glass-effect rounded-3xl shadow-2xl relative z-10 border border-white/10"> | |
| <div class="floating-animation mb-8"> | |
| <div class="w-32 h-32 mx-auto bg-gradient-to-r from-purple-500 to-blue-500 rounded-full flex items-center justify-center shadow-2xl"> | |
| <svg class="w-16 h-16 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path> | |
| </svg> | |
| </div> | |
| </div> | |
| <h1 class="text-6xl font-black mb-6 gradient-text tracking-tight">CharacterVerse</h1> | |
| <p class="text-2xl mb-10 text-gray-200 leading-relaxed">Immerse yourself in an expansive 3D universe where every character has a story. Choose your avatar and embark on an unforgettable journey through dynamic worlds filled with interactive companions.</p> | |
| <button id="start-btn" class="px-12 py-4 bg-gradient-to-r from-purple-600 to-blue-600 rounded-2xl text-white font-bold text-xl hover:from-purple-700 hover:to-blue-700 transition-all transform hover:scale-110 shadow-2xl pulse-glow border border-white/20"> | |
| <span class="flex items-center justify-center space-x-3"> | |
| <span>Begin Epic Journey</span> | |
| <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6"></path> | |
| </svg> | |
| </span> | |
| </button> | |
| <div class="mt-8 flex justify-center space-x-6 text-sm text-gray-400"> | |
| <div class="flex items-center space-x-2"> | |
| <div class="w-2 h-2 bg-green-500 rounded-full"></div> | |
| <span>Real-time 3D Graphics</span> | |
| </div> | |
| <div class="flex items-center space-x-2"> | |
| <div class="w-2 h-2 bg-blue-500 rounded-full"></div> | |
| <span>Interactive Characters</span> | |
| </div> | |
| <div class="flex items-center space-x-2"> | |
| <div class="w-2 h-2 bg-purple-500 rounded-full"></div> | |
| <span>Dynamic Environments</span> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Character Selection Screen --> | |
| <div id="character-selection" class="fixed inset-0 bg-gradient-to-br from-slate-900 via-purple-900 to-slate-900 z-40 transition-all duration-500 opacity-0 pointer-events-none"> | |
| <div class="absolute inset-0 bg-black/20 backdrop-blur-sm"></div> | |
| <div class="container mx-auto px-6 py-8 h-full flex flex-col relative z-10"> | |
| <div class="text-center mb-12"> | |
| <h2 class="text-5xl font-black mb-4 gradient-text">Choose Your Champion</h2> | |
| <p class="text-xl text-gray-300 max-w-2xl mx-auto">Select the perfect avatar that represents your journey. Each character comes with unique abilities and personality traits.</p> | |
| </div> | |
| <div class="flex-1 overflow-y-auto pb-32"> | |
| <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8 max-w-7xl mx-auto"> | |
| <!-- Character cards will be dynamically inserted here --> | |
| </div> | |
| </div> | |
| <div class="fixed bottom-0 left-0 right-0 bg-gradient-to-t from-slate-900/95 via-slate-900/80 to-transparent py-8 px-6 z-50 backdrop-blur-lg border-t border-white/10"> | |
| <div class="container mx-auto text-center"> | |
| <button id="confirm-character" class="px-12 py-4 bg-gradient-to-r from-green-600 to-emerald-500 rounded-2xl text-white font-bold text-lg hover:from-green-700 hover:to-emerald-600 transition-all transform hover:scale-105 shadow-2xl border border-white/20 opacity-0 pulse-glow"> | |
| <span class="flex items-center justify-center space-x-3"> | |
| <span>Confirm Champion</span> | |
| <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path> | |
| </svg> | |
| </span> | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- World Selection Screen --> | |
| <div id="world-selection" class="fixed inset-0 bg-gradient-to-br from-slate-900 via-blue-900 to-slate-900 z-30 transition-all duration-500 opacity-0 pointer-events-none"> | |
| <div class="absolute inset-0 bg-black/20 backdrop-blur-sm"></div> | |
| <div class="container mx-auto px-6 py-8 h-full flex flex-col relative z-10"> | |
| <div class="text-center mb-12"> | |
| <h2 class="text-5xl font-black mb-4 gradient-text">Forge Your Universe</h2> | |
| <p class="text-xl text-gray-300 max-w-2xl mx-auto">Select 2 unique characters to populate your world. Each will bring their own stories and interactions to your adventure.</p> | |
| <div class="mt-4 inline-flex items-center space-x-4 bg-white/5 rounded-full px-6 py-3 border border-white/10"> | |
| <span class="text-green-400 font-semibold" id="selected-count">0</span> | |
| <span class="text-gray-400">/</span> | |
| <span class="text-gray-300">2 Characters Selected</span> | |
| </div> | |
| </div> | |
| <div class="flex-1 overflow-y-auto pb-32"> | |
| <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-8 max-w-7xl mx-auto"> | |
| <!-- World character cards will be dynamically inserted here --> | |
| </div> | |
| </div> | |
| <div class="fixed bottom-0 left-0 right-0 bg-gradient-to-t from-slate-900/95 via-slate-900/80 to-transparent py-8 px-6 z-50 backdrop-blur-lg border-t border-white/10"> | |
| <div class="container mx-auto text-center"> | |
| <button id="start-world" class="px-12 py-4 bg-gradient-to-r from-purple-600 to-blue-600 rounded-2xl text-white font-bold text-lg hover:from-purple-700 hover:to-blue-700 transition-all transform hover:scale-105 shadow-2xl border border-white/20 opacity-0 pulse-glow"> | |
| <span class="flex items-center justify-center space-x-3"> | |
| <span>Generate Dynamic World</span> | |
| <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path> | |
| </svg> | |
| </span> | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Game UI --> | |
| <div id="game-ui" class="fixed inset-0 pointer-events-none z-20 opacity-0 transition-all"> | |
| <div class="absolute bottom-4 left-4 bg-gray-800 bg-opacity-70 p-4 rounded-lg"> | |
| <div class="flex items-center space-x-2"> | |
| <div class="w-3 h-3 rounded-full bg-green-500"></div> | |
| <span>WASD: Move</span> | |
| </div> | |
| <div class="flex items-center space-x-2"> | |
| <div class="w-3 h-3 rounded-full bg-blue-500"></div> | |
| <span>Space: Jump</span> | |
| </div> | |
| <div class="flex items-center space-x-2"> | |
| <div class="w-3 h-3 rounded-full bg-purple-500"></div> | |
| <span>Shift: Run</span> | |
| </div> | |
| <div class="flex items-center space-x-2"> | |
| <div class="w-3 h-3 rounded-full bg-yellow-500"></div> | |
| <span>Near NPC: Talk (Space)</span> | |
| </div> | |
| <div class="flex items-center space-x-2"> | |
| <div class="w-3 h-3 rounded-full bg-red-500"></div> | |
| <span>C: Camera Mode</span> | |
| </div> | |
| <div class="flex items-center space-x-2"> | |
| <div class="w-3 h-3 rounded-full bg-pink-500"></div> | |
| <span>M: Toggle Music</span> | |
| </div> | |
| </div> | |
| <div class="absolute top-4 right-4 bg-gray-800 bg-opacity-70 p-4 rounded-lg"> | |
| <div class="text-white font-semibold">Camera: <span id="camera-mode-display">Follow</span></div> | |
| </div> | |
| <!-- Music Toggle Button --> | |
| <div class="absolute top-4 left-4 bg-gray-800 bg-opacity-70 p-3 rounded-lg music-toggle-container"> | |
| <button id="music-toggle" class="flex items-center space-x-2 text-white hover:text-gray-300 transition-colors"> | |
| <svg id="music-icon" class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24"> | |
| <path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z"/> | |
| </svg> | |
| <span id="music-status" class="text-sm font-semibold">Music: On</span> | |
| </button> | |
| </div> | |
| </div> | |
| <!-- Dialog Box --> | |
| <div id="dialog-box" class="fixed bottom-0 left-0 right-0 bg-gray-900 bg-opacity-90 p-6 rounded-t-2xl transform translate-y-full transition-all duration-300 z-50 max-w-4xl mx-auto"> | |
| <div class="flex items-start space-x-4"> | |
| <div id="dialog-character" class="w-16 h-16 rounded-full bg-gray-700 flex-shrink-0"></div> | |
| <div class="flex-1"> | |
| <h3 id="dialog-name" class="text-xl font-bold mb-2">Character Name</h3> | |
| <p id="dialog-text" class="text-gray-300">Hello there! This is a sample dialog text that will be replaced with actual dialog content.</p> | |
| </div> | |
| </div> | |
| <button id="close-dialog" class="absolute top-4 right-4 text-gray-400 hover:text-white"> | |
| <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /> | |
| </svg> | |
| </button> | |
| </div> | |
| <!-- Canvas for Three.js --> | |
| <canvas id="canvas"></canvas> | |
| <script> | |
| // Game state | |
| const gameState = { | |
| currentScreen: 'welcome', | |
| selectedCharacter: null, | |
| selectedWorldCharacters: [], | |
| characters: [], | |
| worldCharacters: [], | |
| player: null, | |
| npcs: [], | |
| nearbyNpc: null, | |
| isRunning: false, | |
| isJumping: false, | |
| cameraMode: 'follow', // 'follow', 'orbit', 'first-person' | |
| musicEnabled: true, | |
| keys: { | |
| w: false, | |
| a: false, | |
| s: false, | |
| d: false, | |
| shift: false, | |
| space: false | |
| } | |
| }; | |
| // Audio context and music | |
| let audioContext = null; | |
| let musicSource = null; | |
| let musicGain = null; | |
| let isMusicPlaying = false; | |
| // RPM Avatar Manifest Data | |
| const characterData = [ | |
| { | |
| id: "6637232cc6a3e0f03418f723", | |
| name: "David", | |
| modelUrl: "https://models.readyplayer.me/6637232cc6a3e0f03418f723.glb", | |
| color: "#3B82F6", | |
| dialog: ["Hello there! I'm David.", "Nice to meet you!", "Ready for an adventure?"] | |
| }, | |
| { | |
| id: "64e602a9b54bdcd880df8ca3", | |
| name: "Anja", | |
| modelUrl: "https://models.readyplayer.me/64e602a9b54bdcd880df8ca3.glb", | |
| color: "#EC4899", | |
| dialog: ["Hi, I'm Anja!", "Beautiful day, isn't it?", "What brings you here?"] | |
| }, | |
| { | |
| id: "6658826709dff701a3a2955e", | |
| name: "Eli", | |
| modelUrl: "https://models.readyplayer.me/6658826709dff701a3a2955e.glb", | |
| color: "#10B981", | |
| dialog: ["Hey, I'm Eli!", "Love exploring new places.", "What's your story?"] | |
| }, | |
| { | |
| id: "664e502a6ef2fae943a4e5a4", | |
| name: "Julien", | |
| modelUrl: "https://models.readyplayer.me/664e502a6ef2fae943a4e5a4.glb", | |
| color: "#F59E0B", | |
| dialog: ["Julien here!", "Always up for a challenge.", "Let's make this fun!"] | |
| }, | |
| { | |
| id: "680ebd7587f61ba0328013ae", | |
| name: "Dan", | |
| modelUrl: "https://avatars.readyplayer.me/680ebd7587f61ba0328013ae.glb", | |
| color: "#8B5CF6", | |
| dialog: ["Dan reporting for duty!", "Ready when you are.", "Let's do this!"] | |
| }, | |
| { | |
| id: "664e502a6ef2fae943a4e5a4", | |
| name: "Lucy", | |
| modelUrl: "https://avatars.readyplayer.me/664e502a6ef2fae943a4e5a4.glb", | |
| color: "#EF4444", | |
| dialog: ["Hi, I'm Lucy!", "So excited to be here!", "Ready for anything!"] | |
| }, | |
| { | |
| id: "665b2ed436c854537e38cdf8", | |
| name: "Lana", | |
| modelUrl: "https://models.readyplayer.me/665b2ed436c854537e38cdf8.glb", | |
| color: "#6366F1", | |
| dialog: ["Lana at your service!", "What a wonderful world!", "Let's explore together!"] | |
| }, | |
| { | |
| id: "665b2ed436c854537e38cdf8", | |
| name: "Nyx", | |
| modelUrl: "https://models.readyplayer.me/665b2ed436c854537e38cdf8.glb", | |
| color: "#F97316", | |
| dialog: ["I'm Nyx, nice to meet you!", "The night is my domain.", "Ready for adventure!"] | |
| } | |
| ]; | |
| // Three.js variables | |
| let scene, camera, renderer, controls; | |
| let mixer, clock, loader; | |
| let world; | |
| // Initialize the app | |
| document.addEventListener('DOMContentLoaded', async () => { | |
| // Set up UI event listeners | |
| document.getElementById('start-btn').addEventListener('click', showCharacterSelection); | |
| document.getElementById('confirm-character').addEventListener('click', showWorldSelection); | |
| document.getElementById('start-world').addEventListener('click', startGame); | |
| document.getElementById('close-dialog').addEventListener('click', closeDialog); | |
| // Keyboard event listeners | |
| window.addEventListener('keydown', handleKeyDown); | |
| window.addEventListener('keyup', handleKeyUp); | |
| // Camera controls listener | |
| window.addEventListener('keydown', (event) => { | |
| if (event.key.toLowerCase() === 'c') { | |
| cycleCameraMode(); | |
| } | |
| if (event.key.toLowerCase() === 'm') { | |
| toggleMusic(); | |
| } | |
| }); | |
| // Music toggle button listener | |
| document.getElementById('music-toggle').addEventListener('click', toggleMusic); | |
| // Populate character selection | |
| populateCharacterSelection(); | |
| // Initialize audio context on user interaction | |
| document.addEventListener('click', initializeAudio, { once: true }); | |
| // Check if Three.js modules are available | |
| if (typeof THREE === 'undefined') { | |
| console.error('Three.js not loaded'); | |
| // Try to load modules dynamically if needed | |
| loadThreeJSModules(); | |
| } | |
| }); | |
| // Function to dynamically load Three.js modules if needed | |
| function loadThreeJSModules() { | |
| // Check if OrbitControls is available | |
| if (typeof THREE.OrbitControls === 'undefined') { | |
| console.warn('OrbitControls not available, using fallback controls'); | |
| } | |
| // Check if GLTFLoader is available | |
| if (typeof THREE.GLTFLoader === 'undefined') { | |
| console.warn('GLTFLoader not available, using fallback geometry'); | |
| } | |
| } | |
| function initializeAudio() { | |
| if (audioContext) return; | |
| try { | |
| audioContext = new (window.AudioContext || window.webkitAudioContext)(); | |
| musicGain = audioContext.createGain(); | |
| musicGain.connect(audioContext.destination); | |
| musicGain.gain.value = gameState.musicEnabled ? 0.3 : 0; | |
| // Create a simple ambient music | |
| createAmbientMusic(); | |
| } catch (error) { | |
| console.log('Audio not supported:', error); | |
| } | |
| } | |
| function createAmbientMusic() { | |
| if (!audioContext) return; | |
| const oscillator1 = audioContext.createOscillator(); | |
| const oscillator2 = audioContext.createOscillator(); | |
| const lfo = audioContext.createOscillator(); | |
| const lfoGain = audioContext.createGain(); | |
| oscillator1.type = 'sine'; | |
| oscillator1.frequency.value = 220; | |
| oscillator2.type = 'triangle'; | |
| oscillator2.frequency.value = 330; | |
| lfo.type = 'sine'; | |
| lfo.frequency.value = 0.1; | |
| lfoGain.gain.value = 10; | |
| lfo.connect(lfoGain); | |
| lfoGain.connect(oscillator1.frequency); | |
| lfoGain.connect(oscillator2.frequency); | |
| oscillator1.connect(musicGain); | |
| oscillator2.connect(musicGain); | |
| oscillator1.start(); | |
| oscillator2.start(); | |
| lfo.start(); | |
| musicSource = { oscillator1, oscillator2, lfo }; | |
| isMusicPlaying = true; | |
| } | |
| function toggleMusic() { | |
| if (!audioContext || !musicGain) { | |
| initializeAudio(); | |
| return; | |
| } | |
| gameState.musicEnabled = !gameState.musicEnabled; | |
| if (musicGain) { | |
| musicGain.gain.value = gameState.musicEnabled ? 0.3 : 0; | |
| } | |
| // Update UI | |
| const musicIcon = document.getElementById('music-icon'); | |
| const musicStatus = document.getElementById('music-status'); | |
| if (gameState.musicEnabled) { | |
| musicIcon.innerHTML = '<path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z"/>'; | |
| musicStatus.textContent = 'Music: On'; | |
| } else { | |
| musicIcon.innerHTML = '<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/>'; | |
| musicStatus.textContent = 'Music: Off'; | |
| } | |
| } | |
| function showCharacterSelection() { | |
| document.getElementById('welcome-screen').classList.add('opacity-0', 'pointer-events-none'); | |
| document.getElementById('character-selection').classList.remove('opacity-0', 'pointer-events-none'); | |
| } | |
| function showWorldSelection() { | |
| if (!gameState.selectedCharacter) return; | |
| document.getElementById('character-selection').classList.add('opacity-0', 'pointer-events-none'); | |
| document.getElementById('world-selection').classList.remove('opacity-0', 'pointer-events-none'); | |
| // Filter out the selected character from world selection | |
| const availableCharacters = characterData.filter(char => char.id !== gameState.selectedCharacter.id); | |
| populateWorldSelection(availableCharacters); | |
| } | |
| function startGame() { | |
| if (gameState.selectedWorldCharacters.length < 2) { | |
| alert('Please select 2 characters for your world!'); | |
| return; | |
| } | |
| // Ensure audio is initialized when game starts | |
| if (!audioContext) { | |
| initializeAudio(); | |
| } | |
| document.getElementById('world-selection').classList.add('opacity-0', 'pointer-events-none'); | |
| document.getElementById('game-ui').classList.remove('opacity-0'); | |
| // Initialize Three.js world | |
| initThreeJS(); | |
| } | |
| function populateCharacterSelection() { | |
| const container = document.querySelector('#character-selection .grid'); | |
| container.innerHTML = ''; | |
| characterData.forEach(character => { | |
| const card = document.createElement('div'); | |
| card.className = 'character-card bg-gray-800 rounded-xl p-4 cursor-pointer transition-all shadow-lg'; | |
| card.innerHTML = ` | |
| <div class="character-preview mb-4 flex items-center justify-center"> | |
| <div class="w-24 h-24 rounded-full bg-gradient-to-br from-purple-500 to-blue-500 flex items-center justify-center text-white font-bold text-sm"> | |
| ${character.name} | |
| </div> | |
| </div> | |
| <h3 class="text-xl font-bold text-center mb-2 text-white">${character.name}</h3> | |
| <p class="text-gray-300 text-center text-sm">RPM Avatar</p> | |
| `; | |
| card.addEventListener('click', () => { | |
| // Deselect all cards | |
| document.querySelectorAll('.character-card').forEach(c => { | |
| c.classList.remove('ring-2', 'ring-purple-500'); | |
| }); | |
| // Select this card | |
| card.classList.add('ring-2', 'ring-purple-500'); | |
| // Update selected character | |
| gameState.selectedCharacter = character; | |
| // Show confirm button | |
| document.getElementById('confirm-character').classList.remove('opacity-0'); | |
| }); | |
| container.appendChild(card); | |
| }); | |
| } | |
| function populateWorldSelection(characters) { | |
| const container = document.querySelector('#world-selection .grid'); | |
| container.innerHTML = ''; | |
| characters.forEach(character => { | |
| const card = document.createElement('div'); | |
| card.className = `character-card bg-gray-800 rounded-xl p-4 cursor-pointer transition-all shadow-lg ${ | |
| gameState.selectedWorldCharacters.some(c => c.id === character.id) ? 'ring-2 ring-green-500' : '' | |
| }`; | |
| card.innerHTML = ` | |
| <div class="character-preview mb-4 flex items-center justify-center"> | |
| <div class="w-16 h-16 rounded-full bg-gradient-to-br from-purple-500 to-blue-500 flex items-center justify-center text-white font-bold text-xs"> | |
| ${character.name} | |
| </div> | |
| </div> | |
| <h3 class="text-lg font-bold text-center mb-2 text-white">${character.name}</h3> | |
| <p class="text-gray-300 text-sm text-center">Click to ${gameState.selectedWorldCharacters.some(c => c.id === character.id) ? 'deselect' : 'select'}</p> | |
| `; | |
| card.addEventListener('click', () => { | |
| // Check if character is already selected | |
| const index = gameState.selectedWorldCharacters.findIndex(c => c.id === character.id); | |
| if (index !== -1) { | |
| // Deselect | |
| gameState.selectedWorldCharacters.splice(index, 1); | |
| card.classList.remove('ring-2', 'ring-green-500'); | |
| card.querySelector('p').textContent = 'Click to select'; | |
| } else { | |
| // Select if we have less than 2 | |
| if (gameState.selectedWorldCharacters.length < 2) { | |
| gameState.selectedWorldCharacters.push(character); | |
| card.classList.add('ring-2', 'ring-green-500'); | |
| card.querySelector('p').textContent = 'Click to deselect'; | |
| } | |
| } | |
| // Update start world button | |
| if (gameState.selectedWorldCharacters.length === 2) { | |
| document.getElementById('start-world').classList.remove('opacity-0'); | |
| } else { | |
| document.getElementById('start-world').classList.add('opacity-0'); | |
| } | |
| }); | |
| container.appendChild(card); | |
| }); | |
| } | |
| function initThreeJS() { | |
| if (typeof THREE === 'undefined') { | |
| console.error('Three.js is not loaded properly'); | |
| // Load Three.js from a reliable CDN | |
| const script = document.createElement('script'); | |
| script.src = 'https://cdnjs.cloudflare.com/ajax/libs/three.js/r164/three.min.js"></script> | |
| script.onload = () => { | |
| console.log('Three.js loaded successfully'); | |
| initThreeJS(); // Retry initialization | |
| }; | |
| script.onerror = () => { | |
| console.error('Failed to load Three.js'); | |
| alert('Three.js failed to load. Please check your internet connection and refresh the page.'); | |
| }; | |
| document.head.appendChild(script); | |
| return; | |
| } | |
| try { | |
| // Set up Three.js scene | |
| scene = new THREE.Scene(); | |
| scene.background = new THREE.Color(0x87CEEB); // Sky blue | |
| // Camera | |
| camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000); | |
| camera.position.set(0, 5, 10); | |
| // Renderer | |
| const canvas = document.getElementById('canvas'); | |
| renderer = new THREE.WebGLRenderer({ | |
| canvas: canvas, | |
| antialias: true, | |
| alpha: true | |
| }); | |
| renderer.setSize(window.innerWidth, window.innerHeight); | |
| renderer.shadowMap.enabled = true; | |
| renderer.shadowMap.type = THREE.PCFSoftShadowMap; | |
| // Clock for animations | |
| clock = new THREE.Clock(); | |
| // Loader - check if GLTFLoader is available | |
| if (typeof THREE.GLTFLoader !== 'undefined') { | |
| loader = new THREE.GLTFLoader(); | |
| } else { | |
| console.warn('GLTFLoader not available, using fallback geometry'); | |
| loader = null; | |
| } | |
| // Add environment map with error handling | |
| const envMapLoader = new THREE.CubeTextureLoader(); | |
| envMapLoader.load([ | |
| 'https://threejs.org/examples/textures/cube/SwedishRoyalCastle/px.jpg', | |
| 'https://threejs.org/examples/textures/cube/SwedishRoyalCastle/nx.jpg', | |
| 'https://threejs.org/examples/textures/cube/SwedishRoyalCastle/py.jpg', | |
| 'https://threejs.org/examples/textures/cube/SwedishRoyalCastle/ny.jpg', | |
| 'https://threejs.org/examples/textures/cube/SwedishRoyalCastle/pz.jpg', | |
| 'https://threejs.org/examples/textures/cube/SwedishRoyalCastle/nz.jpg' | |
| ], | |
| (envMap) => { | |
| scene.background = envMap; | |
| scene.environment = envMap; | |
| }, | |
| undefined, | |
| (error) => { | |
| console.warn('Failed to load environment map:', error); | |
| // Fallback to plain color background | |
| scene.background = new THREE.Color(0x87CEEB); | |
| }); | |
| // Add better lighting for RPM avatars | |
| const hemisphereLight = new THREE.HemisphereLight(0xffffff, 0x444444, 1); | |
| scene.add(hemisphereLight); | |
| const mainLight = new THREE.DirectionalLight(0xffffff, 0.8); | |
| mainLight.position.set(10, 20, 15); | |
| mainLight.castShadow = true; | |
| mainLight.shadow.mapSize.width = 2048; | |
| mainLight.shadow.mapSize.height = 2048; | |
| mainLight.shadow.camera.near = 0.5; | |
| mainLight.shadow.camera.far = 50; | |
| mainLight.shadow.camera.left = -20; | |
| mainLight.shadow.camera.right = 20; | |
| mainLight.shadow.camera.top = 20; | |
| mainLight.shadow.camera.bottom = -20; | |
| scene.add(mainLight); | |
| // Create ground | |
| const groundGeometry = new THREE.PlaneGeometry(100, 100); | |
| const groundMaterial = new THREE.MeshStandardMaterial({ | |
| color: 0x4ade80, | |
| roughness: 0.8, | |
| metalness: 0.2 | |
| }); | |
| const ground = new THREE.Mesh(groundGeometry, groundMaterial); | |
| ground.rotation.x = -Math.PI / 2; | |
| ground.receiveShadow = true; | |
| scene.add(ground); | |
| // Add some environment objects | |
| addEnvironmentObjects(); | |
| // Initialize OrbitControls with fallback | |
| if (typeof THREE.OrbitControls !== 'undefined') { | |
| controls = new THREE.OrbitControls(camera, renderer.domElement); | |
| controls.enableDamping = true; | |
| controls.dampingFactor = 0.05; | |
| controls.enabled = false; // Start with controls disabled for follow camera | |
| } else { | |
| console.warn('OrbitControls not available, using fallback'); | |
| controls = { | |
| enabled: false, | |
| update: () => {}, | |
| target: new THREE.Vector3() | |
| }; | |
| } | |
| // Load player character | |
| loadPlayerCharacter(); | |
| // Load NPC characters | |
| loadNPCCharacters(); | |
| // Start animation loop | |
| animate(); | |
| // Handle window resize | |
| window.addEventListener('resize', () => { | |
| camera.aspect = window.innerWidth / window.innerHeight; | |
| camera.updateProjectionMatrix(); | |
| renderer.setSize(window.innerWidth, window.innerHeight); | |
| }); | |
| } catch (error) { | |
| console.error('Error initializing Three.js:', error); | |
| // Show user-friendly error message | |
| alert('Error initializing 3D graphics. Please check your browser supports WebGL.'); | |
| } | |
| } | |
| function addEnvironmentObjects() { | |
| // Add some trees | |
| const treeGeometry = new THREE.ConeGeometry(1, 3, 8); | |
| const treeMaterial = new THREE.MeshStandardMaterial({ color: 0x2e7d32 }); | |
| for (let i = 0; i < 20; i++) { | |
| const tree = new THREE.Mesh(treeGeometry, treeMaterial); | |
| tree.position.x = (Math.random() - 0.5) * 80; | |
| tree.position.z = (Math.random() - 0.5) * 80; | |
| tree.position.y = 1.5; | |
| tree.castShadow = true; | |
| scene.add(tree); | |
| // Add trunk | |
| const trunkGeometry = new THREE.CylinderGeometry(0.3, 0.3, 1); | |
| const trunkMaterial = new THREE.MeshStandardMaterial({ color: 0x5e4035 }); | |
| const trunk = new THREE.Mesh(trunkGeometry, trunkMaterial); | |
| trunk.position.y = 0.5; | |
| tree.add(trunk); | |
| } | |
| // Add some rocks | |
| const rockGeometry = new THREE.SphereGeometry(0.5, 8, 8); | |
| const rockMaterial = new THREE.MeshStandardMaterial({ color: 0x757575 }); | |
| for (let i = 0; i < 15; i++) { | |
| const rock = new THREE.Mesh(rockGeometry, rockMaterial); | |
| rock.position.x = (Math.random() - 0.5) * 80; | |
| rock.position.z = (Math.random() - 0.5) * 80; | |
| rock.position.y = 0.5; | |
| rock.castShadow = true; | |
| scene.add(rock); | |
| } | |
| } | |
| function loadPlayerCharacter() { | |
| if (loader && typeof THREE.GLTFLoader !== 'undefined') { | |
| loader.load(gameState.selectedCharacter.modelUrl, (gltf) => { | |
| const model = gltf.scene; | |
| // Scale and position the RPM avatar model - adjusted for human proportions | |
| model.scale.set(0.8, 0.8, 0.8); | |
| model.position.set(0, 0, 0); | |
| // Enable shadows for all children | |
| model.traverse((child) => { | |
| if (child.isMesh) { | |
| child.castShadow = true; | |
| child.receiveShadow = true; | |
| } | |
| }); | |
| scene.add(model); | |
| // Set up animations if available | |
| const mixer = new THREE.AnimationMixer(model); | |
| const animations = gltf.animations; | |
| gameState.player = { | |
| model: model, | |
| speed: 0.08, | |
| runSpeed: 0.15, | |
| rotationSpeed: 0.08, | |
| isMoving: false, | |
| mixer: mixer, | |
| animations: animations, | |
| currentAnimation: null, | |
| animationActions: {} | |
| }; | |
| // Set up animation actions | |
| if (animations && animations.length > 0) { | |
| animations.forEach((clip) => { | |
| gameState.player.animationActions[clip.name] = mixer.clipAction(clip); | |
| }); | |
| // Play the first animation by default | |
| if (animations[0]) { | |
| gameState.player.animationActions[animations[0].name].play(); | |
| gameState.player.currentAnimation = animations[0].name; | |
| } | |
| } else { | |
| // If no animations, create simple placeholder animations | |
| createPlayerAnimations(); | |
| } | |
| }, undefined, (error) => { | |
| console.error('Error loading player model:', error); | |
| // Fallback to placeholder | |
| loadPlayerCharacterFallback(); | |
| }); | |
| } else { | |
| // GLTFLoader not available, use fallback | |
| loadPlayerCharacterFallback(); | |
| } | |
| } | |
| function loadPlayerCharacterFallback() { | |
| const group = new THREE.Group(); | |
| // Body | |
| const bodyGeometry = new THREE.CylinderGeometry(0.5, 0.5, 1.5, 8); | |
| const bodyMaterial = new THREE.MeshStandardMaterial({ | |
| color: new THREE.Color(gameState.selectedCharacter.color), | |
| roughness: 0.7, | |
| metalness: 0.1 | |
| }); | |
| const body = new THREE.Mesh(bodyGeometry, bodyMaterial); | |
| body.position.y = 0.75; | |
| body.castShadow = true; | |
| group.add(body); | |
| // Head | |
| const headGeometry = new THREE.SphereGeometry(0.4, 8, 8); | |
| const headMaterial = new THREE.MeshStandardMaterial({ color: 0xf5d0b5 }); | |
| const head = new THREE.Mesh(headGeometry, headMaterial); | |
| head.position.y = 1.6; | |
| head.castShadow = true; | |
| group.add(head); | |
| // Arms | |
| const armGeometry = new THREE.CylinderGeometry(0.15, 0.15, 0.8, 6); | |
| const leftArm = new THREE.Mesh(armGeometry, bodyMaterial); | |
| leftArm.position.set(-0.6, 1, 0); | |
| leftArm.rotation.z = 0.5; | |
| leftArm.castShadow = true; | |
| group.add(leftArm); | |
| const rightArm = new THREE.Mesh(armGeometry, bodyMaterial); | |
| rightArm.position.set(0.6, 1, 0); | |
| rightArm.rotation.z = -0.5; | |
| rightArm.castShadow = true; | |
| group.add(rightArm); | |
| // Legs | |
| const legGeometry = new THREE.CylinderGeometry(0.2, 0.2, 0.8, 6); | |
| const leftLeg = new THREE.Mesh(legGeometry, new THREE.MeshStandardMaterial({ color: 0x1e40af })); | |
| leftLeg.position.set(-0.2, -0.4, 0); | |
| leftLeg.castShadow = true; | |
| group.add(leftLeg); | |
| const rightLeg = new THREE.Mesh(legGeometry, new THREE.MeshStandardMaterial({ color: 0x1e40af })); | |
| rightLeg.position.set(0.2, -0.4, 0); | |
| rightLeg.castShadow = true; | |
| group.add(rightLeg); | |
| group.position.y = 0; | |
| scene.add(group); | |
| gameState.player = { | |
| model: group, | |
| speed: 0.08, | |
| runSpeed: 0.15, | |
| rotationSpeed: 0.08, | |
| isMoving: false, | |
| animations: { | |
| idle: null, | |
| walk: null, | |
| run: null, | |
| jump: null | |
| }, | |
| currentAnimation: null | |
| }; | |
| // Add a simple animation mixer for the player | |
| mixer = new THREE.AnimationMixer(group); | |
| createPlayerAnimations(); | |
| setPlayerAnimation('idle'); | |
| } | |
| function createPlayerAnimations() { | |
| // In a real app, these would come from the GLTF model | |
| // For this example, we'll create simple animations using NumberKeyframeTrack | |
| // Idle animation (slight bounce) | |
| const idleTrack = new THREE.NumberKeyframeTrack( | |
| '.position[y]', | |
| [0, 0.5, 1], | |
| [0, 0.1, 0] | |
| ); | |
| const idleClip = new THREE.AnimationClip('idle', 1, [idleTrack]); | |
| gameState.player.animations.idle = idleClip; | |
| // Walk animation (arm and leg movement) | |
| const leftArmTrack = new THREE.NumberKeyframeTrack( | |
| '.children[2].rotation[z]', | |
| [0, 0.5, 1], | |
| [0.3, -0.3, 0.3] | |
| ); | |
| const rightArmTrack = new THREE.NumberKeyframeTrack( | |
| '.children[3].rotation[z]', | |
| [0, 0.5, 1], | |
| [-0.3, 0.3, -0.3] | |
| ); | |
| const leftLegTrack = new THREE.NumberKeyframeTrack( | |
| '.children[4].position[y]', | |
| [0, 0.5, 1], | |
| [-0.4, -0.3, -0.4] | |
| ); | |
| const rightLegTrack = new THREE.NumberKeyframeTrack( | |
| '.children[5].position[y]', | |
| [0, 0.5, 1], | |
| [-0.3, -0.4, -0.3] | |
| ); | |
| const walkClip = new THREE.AnimationClip('walk', 0.5, [ | |
| leftArmTrack, rightArmTrack, leftLegTrack, rightLegTrack | |
| ]); | |
| gameState.player.animations.walk = walkClip; | |
| // Run animation (faster arm and leg movement) | |
| const runClip = new THREE.AnimationClip('run', 0.3, [ | |
| leftArmTrack, rightArmTrack, leftLegTrack, rightLegTrack | |
| ]); | |
| gameState.player.animations.run = runClip; | |
| // Jump animation | |
| const jumpTrack = new THREE.NumberKeyframeTrack( | |
| '.position[y]', | |
| [0, 0.2, 0.4, 0.6, 0.8, 1], | |
| [0, 1.5, 1, 0.3, 0, 0] | |
| ); | |
| const jumpClip = new THREE.AnimationClip('jump', 1, [jumpTrack]); | |
| gameState.player.animations.jump = jumpClip; | |
| } | |
| function setPlayerAnimation(name) { | |
| if (gameState.player.currentAnimation === name) return; | |
| if (mixer) { | |
| mixer.stopAllAction(); | |
| const clip = gameState.player.animations[name]; | |
| if (clip) { | |
| const action = mixer.clipAction(clip); | |
| action.setLoop(THREE.LoopRepeat); | |
| action.clampWhenFinished = false; | |
| action.play(); | |
| } | |
| gameState.player.currentAnimation = name; | |
| } | |
| } | |
| function loadNPCCharacters() { | |
| gameState.selectedWorldCharacters.forEach((character, index) => { | |
| // In a real app, we would load the GLTF model from the URL | |
| // For this example, we'll create a simple placeholder | |
| const group = new THREE.Group(); | |
| // Body | |
| const bodyGeometry = new THREE.CylinderGeometry(0.5, 0.5, 1.5, 8); | |
| const bodyMaterial = new THREE.MeshStandardMaterial({ | |
| color: new THREE.Color(character.color), | |
| roughness: 0.7, | |
| metalness: 0.1 | |
| }); | |
| const body = new THREE.Mesh(bodyGeometry, bodyMaterial); | |
| body.position.y = 0.75; | |
| body.castShadow = true; | |
| group.add(body); | |
| // Head | |
| const headGeometry = new THREE.SphereGeometry(0.4, 8, 8); | |
| const headMaterial = new THREE.MeshStandardMaterial({ color: 0xf5d0b5 }); | |
| const head = new THREE.Mesh(headGeometry, headMaterial); | |
| head.position.y = 1.6; | |
| head.castShadow = true; | |
| group.add(head); | |
| // Scale NPCs to match player size | |
| group.scale.set(0.8, 0.8, 0.8); | |
| // Position NPCs in a circle around the center | |
| const angle = (index / gameState.selectedWorldCharacters.length) * Math.PI * 2; | |
| const radius = 10 + Math.random() * 10; | |
| group.position.x = Math.cos(angle) * radius; | |
| group.position.z = Math.sin(angle) * radius; | |
| group.position.y = 0; | |
| // Make NPC face center | |
| group.lookAt(0, 0, 0); | |
| scene.add(group); | |
| gameState.npcs.push({ | |
| model: group, | |
| character: character, | |
| dialog: character.dialog, | |
| currentDialogIndex: 0 | |
| }); | |
| }); | |
| } | |
| function animate() { | |
| requestAnimationFrame(animate); | |
| const delta = Math.min(clock.getDelta(), 0.1); // Cap delta to prevent large jumps | |
| // Update player animation mixer | |
| if (mixer) { | |
| mixer.update(delta); | |
| } | |
| // Handle player movement | |
| handlePlayerMovement(delta); | |
| // Check for nearby NPCs | |
| checkForNearbyNPCs(); | |
| // Update camera based on mode | |
| updateCamera(delta); | |
| // Update OrbitControls if enabled | |
| if (controls && controls.enabled) { | |
| controls.update(); | |
| } | |
| renderer.render(scene, camera); | |
| } | |
| function handlePlayerMovement(delta) { | |
| if (!gameState.player) return; | |
| const player = gameState.player; | |
| let moving = false; | |
| let moveDirection = new THREE.Vector3(); | |
| // Check if dialog is open - disable movement if dialog is active | |
| const isDialogOpen = !document.getElementById('dialog-box').classList.contains('translate-y-full'); | |
| if (isDialogOpen) { | |
| // Stop any movement and reset animation | |
| if (player.isMoving) { | |
| player.isMoving = false; | |
| updatePlayerAnimation(); | |
| } | |
| return; | |
| } | |
| // Forward/backward movement | |
| if (gameState.keys.w) { | |
| moveDirection.z = -1; | |
| moving = true; | |
| } | |
| if (gameState.keys.s) { | |
| moveDirection.z = 1; | |
| moving = true; | |
| } | |
| // Left/right movement | |
| if (gameState.keys.a) { | |
| moveDirection.x = -1; | |
| moving = true; | |
| } | |
| if (gameState.keys.d) { | |
| moveDirection.x = 1; | |
| moving = true; | |
| } | |
| // Normalize direction vector if moving diagonally | |
| if (moving) { | |
| moveDirection.normalize(); | |
| // Calculate speed with delta time for consistent movement | |
| const speed = (gameState.keys.shift ? player.runSpeed : player.speed) * delta * 60; | |
| // Apply movement relative to camera direction | |
| const cameraForward = new THREE.Vector3(); | |
| camera.getWorldDirection(cameraForward); | |
| cameraForward.y = 0; | |
| cameraForward.normalize(); | |
| const cameraRight = new THREE.Vector3(); | |
| cameraRight.crossVectors(cameraForward, new THREE.Vector3(0, 1, 0)); | |
| cameraRight.normalize(); | |
| const moveVector = new THREE.Vector3(); | |
| moveVector.addScaledVector(cameraForward, moveDirection.z * speed); | |
| moveVector.addScaledVector(cameraRight, moveDirection.x * speed); | |
| player.model.position.add(moveVector); | |
| // Update player rotation to face movement direction | |
| if (moveDirection.length() > 0.1) { | |
| const targetRotation = Math.atan2(moveDirection.x, moveDirection.z) + camera.rotation.y; | |
| player.model.rotation.y = THREE.MathUtils.lerp(player.model.rotation.y, targetRotation, player.rotationSpeed * delta * 60); | |
| } | |
| } | |
| // Jumping - only trigger when not showing dialog and not already jumping | |
| if (gameState.keys.space && !gameState.isJumping && !isDialogOpen) { | |
| gameState.isJumping = true; | |
| setPlayerAnimation('jump'); | |
| // Create a more robust jump animation | |
| let jumpStartTime = performance.now(); | |
| const jumpDuration = 1000; // 1 second | |
| const originalY = player.model.position.y; | |
| const jumpHeight = 1.5; | |
| function performJump() { | |
| const currentTime = performance.now(); | |
| const elapsed = currentTime - jumpStartTime; | |
| const progress = Math.min(elapsed / jumpDuration, 1); | |
| // Parabolic jump curve | |
| const jumpProgress = 1 - Math.pow(2 * progress - 1, 2); | |
| player.model.position.y = originalY + jumpHeight * jumpProgress; | |
| if (progress < 1) { | |
| requestAnimationFrame(performJump); | |
| } else { | |
| gameState.isJumping = false; | |
| player.model.position.y = originalY; | |
| updatePlayerAnimation(); | |
| } | |
| } | |
| performJump(); | |
| } | |
| // Update animation based on movement | |
| if (moving !== player.isMoving || gameState.keys.shift) { | |
| player.isMoving = moving; | |
| updatePlayerAnimation(); | |
| } | |
| // Update camera position based on camera mode | |
| updateCamera(delta); | |
| } | |
| function updateCamera(delta) { | |
| if (!gameState.player) return; | |
| const player = gameState.player; | |
| switch (gameState.cameraMode) { | |
| case 'follow': | |
| // Follow camera - smooth follow behind player | |
| const targetCameraPosition = player.model.position.clone().add(new THREE.Vector3(0, 3, 8)); | |
| camera.position.lerp(targetCameraPosition, 0.1 * delta * 60); | |
| camera.lookAt(player.model.position); | |
| break; | |
| case 'orbit': | |
| // Orbit camera - let OrbitControls handle positioning | |
| // Set the target to player position | |
| controls.target.copy(player.model.position); | |
| break; | |
| case 'first-person': | |
| // First-person camera - attach to player's head | |
| const headPosition = player.model.position.clone(); | |
| headPosition.y += 1.2; // Eye level | |
| camera.position.copy(headPosition); | |
| // Make camera face same direction as player | |
| camera.rotation.y = player.model.rotation.y; | |
| break; | |
| } | |
| } | |
| function cycleCameraMode() { | |
| const modes = ['follow', 'orbit', 'first-person']; | |
| const currentIndex = modes.indexOf(gameState.cameraMode); | |
| const nextIndex = (currentIndex + 1) % modes.length; | |
| gameState.cameraMode = modes[nextIndex]; | |
| // Update UI display | |
| document.getElementById('camera-mode-display').textContent = | |
| gameState.cameraMode.charAt(0).toUpperCase() + gameState.cameraMode.slice(1); | |
| // Enable/disable OrbitControls based on mode | |
| if (controls && typeof controls.enabled !== 'undefined') { | |
| controls.enabled = (gameState.cameraMode === 'orbit'); | |
| } | |
| // Reset camera position for first-person mode | |
| if (gameState.cameraMode === 'first-person') { | |
| camera.rotation.set(0, 0, 0); | |
| } | |
| } | |
| function updatePlayerAnimation() { | |
| if (gameState.isJumping) return; | |
| if (!gameState.player.isMoving) { | |
| setPlayerAnimation('idle'); | |
| } else if (gameState.keys.shift) { | |
| setPlayerAnimation('run'); | |
| } else { | |
| setPlayerAnimation('walk'); | |
| } | |
| } | |
| function checkForNearbyNPCs() { | |
| if (!gameState.player) return; | |
| let closestNpc = null; | |
| let closestDistance = Infinity; | |
| gameState.npcs.forEach(npc => { | |
| const distance = npc.model.position.distanceTo(gameState.player.model.position); | |
| if (distance < 3 && distance < closestDistance) { // Reduced distance from 5 to 3 | |
| closestDistance = distance; | |
| closestNpc = npc; | |
| } | |
| }); | |
| gameState.nearbyNpc = closestNpc; | |
| // Add visual feedback when near NPC | |
| const dialogBox = document.getElementById('dialog-box'); | |
| if (closestNpc && !dialogBox.classList.contains('translate-y-full')) { | |
| // Dialog is already open, no need to add feedback | |
| } else if (closestNpc) { | |
| // Show talk prompt | |
| const gameUI = document.getElementById('game-ui'); | |
| if (!gameUI.querySelector('.npc-talk-prompt')) { | |
| const prompt = document.createElement('div'); | |
| prompt.className = 'npc-talk-prompt absolute bottom-20 left-4 bg-yellow-500 text-black px-3 py-2 rounded-lg font-bold'; | |
| prompt.textContent = 'Press SPACE to talk'; | |
| gameUI.appendChild(prompt); | |
| } | |
| } else { | |
| // Remove talk prompt if no NPC nearby | |
| const existingPrompt = document.getElementById('game-ui')?.querySelector('.npc-talk-prompt'); | |
| if (existingPrompt) { | |
| existingPrompt.remove(); | |
| } | |
| } | |
| } | |
| function showDialog(npc) { | |
| if (!npc) return; | |
| // Get next dialog line | |
| const dialog = npc.dialog[npc.currentDialogIndex]; | |
| npc.currentDialogIndex = (npc.currentDialogIndex + 1) % npc.dialog.length; | |
| // Update dialog UI | |
| document.getElementById('dialog-character').style.backgroundColor = npc.character.color; | |
| document.getElementById('dialog-name').textContent = npc.character.name; | |
| document.getElementById('dialog-text').textContent = dialog; | |
| // Show dialog box | |
| document.getElementById('dialog-box').classList.remove('translate-y-full'); | |
| // Remove the talk prompt when dialog opens | |
| const existingPrompt = document.getElementById('game-ui')?.querySelector('.npc-talk-prompt'); | |
| if (existingPrompt) { | |
| existingPrompt.remove(); | |
| } | |
| } | |
| function closeDialog() { | |
| document.getElementById('dialog-box').classList.add('translate-y-full'); | |
| // Remove the talk prompt when dialog closes | |
| const existingPrompt = document.getElementById('game-ui')?.querySelector('.npc-talk-prompt'); | |
| if (existingPrompt) { | |
| existingPrompt.remove(); | |
| } | |
| } | |
| function handleKeyDown(event) { | |
| // Prevent default for space to avoid page scrolling | |
| if (event.key === ' ') { | |
| event.preventDefault(); | |
| } | |
| // Ignore key repeats | |
| if (event.repeat) return; | |
| const key = event.key.toLowerCase(); | |
| switch (key) { | |
| case 'w': | |
| gameState.keys.w = true; | |
| break; | |
| case 'a': | |
| gameState.keys.a = true; | |
| break; | |
| case 's': | |
| gameState.keys.s = true; | |
| break; | |
| case 'd': | |
| gameState.keys.d = true; | |
| break; | |
| case 'shift': | |
| gameState.keys.shift = true; | |
| break; | |
| case ' ': | |
| gameState.keys.space = true; | |
| // Check if we're near an NPC and dialog isn't already open | |
| const isDialogOpen = !document.getElementById('dialog-box').classList.contains('translate-y-full'); | |
| if (gameState.nearbyNpc && !gameState.isJumping && !isDialogOpen) { | |
| showDialog(gameState.nearbyNpc); | |
| } | |
| break; | |
| case 'c': | |
| // Camera mode switching is handled separately | |
| break; | |
| case 'm': | |
| // Music toggle is handled separately | |
| break; | |
| } | |
| } | |
| function handleKeyUp(event) { | |
| // Ignore key repeats | |
| if (event.repeat) return; | |
| const key = event.key.toLowerCase(); | |
| switch (key) { | |
| case 'w': | |
| gameState.keys.w = false; | |
| break; | |
| case 'a': | |
| gameState.keys.a = false; | |
| break; | |
| case 's': | |
| gameState.keys.s = false; | |
| break; | |
| case 'd': | |
| gameState.keys.d = false; | |
| break; | |
| case 'shift': | |
| gameState.keys.shift = false; | |
| break; | |
| case ' ': | |
| gameState.keys.space = false; | |
| break; | |
| } | |
| // Update animation when keys are released | |
| if (gameState.player) { | |
| updatePlayerAnimation(); | |
| } | |
| } | |
| </script> | |
| <p style="border-radius: 8px; text-align: center; font-size: 12px; color: #fff; margin-top: 16px;position: fixed; left: 8px; bottom: 8px; z-index: 10; background: rgba(0, 0, 0, 0.8); padding: 4px 8px;">Made with <img src="https://enzostvs-deepsite.hf.space/logo.svg" alt="DeepSite Logo" style="width: 16px; height: 16px; vertical-align: middle;display:inline-block;margin-right:3px;filter:brightness(0) invert(1);"><a href="https://enzostvs-deepsite.hf.space" style="color: #fff;text-decoration: underline;" target="_blank" >DeepSite</a> - 🧬 <a href="https://enzostvs-deepsite.hf.space?remix=KBLLR/character-selector" style="color: #fff;text-decoration: underline;" target="_blank" >Remix</a></p></body> | |
| </html> |