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://cdn.jsdelivr.net/npm/three@0.132.2/build/three.min.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/three@0.132.2/examples/js/controls/OrbitControls.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/three@0.132.2/examples/js/loaders/GLTFLoader.js"></script> | |
| <style> | |
| body { | |
| margin: 0; | |
| overflow: hidden; | |
| font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; | |
| } | |
| #canvas { | |
| display: block; | |
| } | |
| .transition-all { | |
| transition: all 0.3s ease; | |
| } | |
| .character-card:hover { | |
| transform: translateY(-5px); | |
| box-shadow: 0 10px 20px rgba(0, 0, 0, 0.2); | |
| } | |
| .dialog-box { | |
| background: rgba(0, 0, 0, 0.8); | |
| backdrop-filter: blur(5px); | |
| } | |
| .character-preview { | |
| width: 100%; | |
| height: 200px; | |
| background: rgba(255, 255, 255, 0.1); | |
| border-radius: 0.5rem; | |
| } | |
| </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-gray-900 z-50 transition-all duration-500"> | |
| <div class="text-center max-w-2xl p-8 bg-gray-800 rounded-xl shadow-2xl"> | |
| <h1 class="text-5xl font-bold mb-6 bg-gradient-to-r from-purple-500 to-blue-500 bg-clip-text text-transparent">3D Character World</h1> | |
| <p class="text-xl mb-8 text-gray-300">Explore a vibrant 3D world with interactive characters. Select your avatar and meet others along your journey!</p> | |
| <button id="start-btn" class="px-8 py-3 bg-gradient-to-r from-purple-600 to-blue-600 rounded-full text-white font-bold text-lg hover:from-purple-700 hover:to-blue-700 transition-all transform hover:scale-105 shadow-lg"> | |
| Begin Adventure | |
| </button> | |
| </div> | |
| </div> | |
| <!-- Character Selection Screen --> | |
| <div id="character-selection" class="fixed inset-0 bg-gray-900 z-40 transition-all duration-500 opacity-0 pointer-events-none"> | |
| <div class="container mx-auto px-4 py-8 h-full flex flex-col"> | |
| <h2 class="text-3xl font-bold mb-8 text-center">Choose Your Character</h2> | |
| <div class="flex-1 overflow-y-auto pb-24"> | |
| <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6"> | |
| <!-- Character cards will be dynamically inserted here --> | |
| </div> | |
| </div> | |
| <div class="fixed bottom-0 left-0 right-0 bg-gray-900 border-t border-gray-700 py-4 px-4 z-50"> | |
| <div class="container mx-auto text-center"> | |
| <button id="confirm-character" class="px-8 py-3 bg-green-600 rounded-full text-white font-bold text-lg hover:bg-green-700 transition-all transform hover:scale-105 shadow-lg opacity-0"> | |
| Confirm Selection | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- World Selection Screen --> | |
| <div id="world-selection" class="fixed inset-0 bg-gray-900 z-30 transition-all duration-500 opacity-0 pointer-events-none"> | |
| <div class="container mx-auto px-4 py-8 h-full flex flex-col"> | |
| <h2 class="text-3xl font-bold mb-8 text-center">Select 5 Characters for Your World</h2> | |
| <div class="flex-1 overflow-y-auto pb-24"> | |
| <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-6"> | |
| <!-- World character cards will be dynamically inserted here --> | |
| </div> | |
| </div> | |
| <div class="fixed bottom-0 left-0 right-0 bg-gray-900 border-t border-gray-700 py-4 px-4 z-50"> | |
| <div class="container mx-auto text-center"> | |
| <button id="start-world" class="px-8 py-3 bg-green-600 rounded-full text-white font-bold text-lg hover:bg-green-700 transition-all transform hover:scale-105 shadow-lg opacity-0"> | |
| Generate World | |
| </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> | |
| </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, | |
| keys: { | |
| w: false, | |
| a: false, | |
| s: false, | |
| d: false, | |
| shift: false, | |
| space: false | |
| } | |
| }; | |
| // Sample character data (in a real app, these would be fetched from Google Cloud) | |
| const characterData = [ | |
| { id: 1, name: "Warrior", modelUrl: "https://storage.googleapis.com/your-bucket-name/warrior.glb", color: "#EF4444", dialog: ["I fight for honor!", "The battlefield calls to me.", "Stay sharp!"] }, | |
| { id: 2, name: "Mage", modelUrl: "https://storage.googleapis.com/your-bucket-name/mage.glb", color: "#3B82F6", dialog: ["Magic flows through me.", "The arcane arts are limitless.", "Knowledge is power."] }, | |
| { id: 3, name: "Rogue", modelUrl: "https://storage.googleapis.com/your-bucket-name/rogue.glb", color: "#10B981", dialog: ["Shadows are my friends.", "Quick and quiet.", "Gold is always the answer."] }, | |
| { id: 4, name: "Archer", modelUrl: "https://storage.googleapis.com/your-bucket-name/archer.glb", color: "#F59E0B", dialog: ["My arrows never miss.", "The wind guides my shots.", "Aim true!"] }, | |
| { id: 5, name: "Cleric", modelUrl: "https://storage.googleapis.com/your-bucket-name/cleric.glb", color: "#8B5CF6", dialog: ["The light protects us.", "Healing is my calling.", "Have faith!"] }, | |
| { id: 6, name: "Bard", modelUrl: "https://storage.googleapis.com/your-bucket-name/bard.glb", color: "#EC4899", dialog: ["Let me sing you a tale!", "Music soothes the soul.", "Every story deserves a song."] }, | |
| { id: 7, name: "Monk", modelUrl: "https://storage.googleapis.com/your-bucket-name/monk.glb", color: "#6366F1", dialog: ["Inner peace is key.", "The body and mind are one.", "Discipline brings strength."] }, | |
| { id: 8, name: "Paladin", modelUrl: "https://storage.googleapis.com/your-bucket-name/paladin.glb", color: "#F97316", dialog: ["Justice will prevail!", "By my oath, I protect.", "Evil shall not pass."] } | |
| ]; | |
| // Three.js variables | |
| let scene, camera, renderer, controls; | |
| let mixer, clock, loader; | |
| let world; | |
| // Initialize the app | |
| document.addEventListener('DOMContentLoaded', () => { | |
| // 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); | |
| // Populate character selection | |
| populateCharacterSelection(); | |
| }); | |
| 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 < 5) { | |
| alert('Please select 5 characters for your world!'); | |
| return; | |
| } | |
| 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" style="background-color: ${character.color};"></div> | |
| </div> | |
| <h3 class="text-xl font-bold text-center mb-2">${character.name}</h3> | |
| <p class="text-gray-400 text-center">Click to select</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" style="background-color: ${character.color};"></div> | |
| </div> | |
| <h3 class="text-lg font-bold text-center mb-2">${character.name}</h3> | |
| <p class="text-gray-400 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 5 | |
| if (gameState.selectedWorldCharacters.length < 5) { | |
| 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 === 5) { | |
| document.getElementById('start-world').classList.remove('opacity-0'); | |
| } else { | |
| document.getElementById('start-world').classList.add('opacity-0'); | |
| } | |
| }); | |
| container.appendChild(card); | |
| }); | |
| } | |
| function initThreeJS() { | |
| // 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 | |
| renderer = new THREE.WebGLRenderer({ canvas: document.getElementById('canvas'), antialias: true }); | |
| renderer.setSize(window.innerWidth, window.innerHeight); | |
| renderer.shadowMap.enabled = true; | |
| // Clock for animations | |
| clock = new THREE.Clock(); | |
| // Loader | |
| loader = new THREE.GLTFLoader(); | |
| // Add lights | |
| const ambientLight = new THREE.AmbientLight(0xffffff, 0.5); | |
| scene.add(ambientLight); | |
| const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8); | |
| directionalLight.position.set(5, 10, 7); | |
| directionalLight.castShadow = true; | |
| directionalLight.shadow.mapSize.width = 2048; | |
| directionalLight.shadow.mapSize.height = 2048; | |
| scene.add(directionalLight); | |
| // 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(); | |
| // 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); | |
| }); | |
| } | |
| 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() { | |
| // 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(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.1, | |
| runSpeed: 0.2, | |
| rotationSpeed: 0.05, | |
| 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); | |
| // Create simple animations | |
| createPlayerAnimations(); | |
| // Set initial animation | |
| setPlayerAnimation('idle'); | |
| } | |
| function createPlayerAnimations() { | |
| // In a real app, these would come from the GLTF model | |
| // For this example, we'll create simple animations | |
| // Idle animation (slight bounce) | |
| const idleTrack = new THREE.VectorKeyframeTrack( | |
| '.position', | |
| [0, 0.5, 1], | |
| [ | |
| 0, 0, 0, // Start position | |
| 0, 0.05, 0, // Up position | |
| 0, 0, 0 // Back to start | |
| ] | |
| ); | |
| const idleClip = new THREE.AnimationClip('idle', 1, [idleTrack]); | |
| gameState.player.animations.idle = idleClip; | |
| // Walk animation (arm and leg movement) | |
| const leftArmTrack = new THREE.VectorKeyframeTrack( | |
| '.children[2].rotation[z]', | |
| [0, 0.5, 1], | |
| [0.5, -0.5, 0.5] | |
| ); | |
| const rightArmTrack = new THREE.VectorKeyframeTrack( | |
| '.children[3].rotation[z]', | |
| [0, 0.5, 1], | |
| [-0.5, 0.5, -0.5] | |
| ); | |
| const leftLegTrack = new THREE.VectorKeyframeTrack( | |
| '.children[4].position[y]', | |
| [0, 0.5, 1], | |
| [-0.4, -0.2, -0.4] | |
| ); | |
| const rightLegTrack = new THREE.VectorKeyframeTrack( | |
| '.children[5].position[y]', | |
| [0, 0.5, 1], | |
| [-0.2, -0.4, -0.2] | |
| ); | |
| 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.VectorKeyframeTrack( | |
| '.position[y]', | |
| [0, 0.2, 0.4, 0.6, 0.8, 1], | |
| [0, 2, 1.5, 0.5, 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.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); | |
| // 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() { | |
| if (!gameState.player) { | |
| requestAnimationFrame(animate); | |
| return; | |
| } | |
| const delta = clock.getDelta(); | |
| // Update player animation mixer | |
| if (mixer) { | |
| mixer.update(delta); | |
| } | |
| // Handle player movement | |
| handlePlayerMovement(delta); | |
| // Check for nearby NPCs | |
| checkForNearbyNPCs(); | |
| renderer.render(scene, camera); | |
| requestAnimationFrame(animate); | |
| } | |
| function handlePlayerMovement(delta) { | |
| if (!gameState.player) return; | |
| const player = gameState.player; | |
| let moving = false; | |
| // Forward/backward movement | |
| if (gameState.keys.w) { | |
| player.model.translateZ(-player.speed * (gameState.keys.shift ? player.runSpeed / player.speed : 1)); | |
| moving = true; | |
| } | |
| if (gameState.keys.s) { | |
| player.model.translateZ(player.speed); | |
| moving = true; | |
| } | |
| // Left/right movement | |
| if (gameState.keys.a) { | |
| player.model.translateX(-player.speed); | |
| moving = true; | |
| } | |
| if (gameState.keys.d) { | |
| player.model.translateX(player.speed); | |
| moving = true; | |
| } | |
| // Rotation | |
| if (moving) { | |
| // Calculate target rotation based on movement direction | |
| const targetRotation = Math.atan2( | |
| (gameState.keys.a ? -1 : 0) + (gameState.keys.d ? 1 : 0), | |
| (gameState.keys.w ? -1 : 0) + (gameState.keys.s ? 1 : 0) | |
| ); | |
| // Smoothly rotate towards target | |
| player.model.rotation.y = THREE.MathUtils.lerp( | |
| player.model.rotation.y, | |
| targetRotation, | |
| player.rotationSpeed | |
| ); | |
| } | |
| // Jumping - only trigger when not showing dialog and not already jumping | |
| if (gameState.keys.space && !gameState.isJumping && !document.getElementById('dialog-box').classList.contains('translate-y-full')) { | |
| gameState.isJumping = true; | |
| setPlayerAnimation('jump'); | |
| setTimeout(() => { | |
| gameState.isJumping = false; | |
| updatePlayerAnimation(); | |
| }, 1000); | |
| } | |
| // Update animation based on movement | |
| if (moving !== player.isMoving || gameState.keys.shift) { | |
| player.isMoving = moving; | |
| updatePlayerAnimation(); | |
| } | |
| // Update camera position to follow player | |
| const cameraOffset = new THREE.Vector3(0, 5, 10); | |
| cameraOffset.applyQuaternion(player.model.quaternion); | |
| camera.position.copy(player.model.position.clone().add(cameraOffset)); | |
| camera.lookAt(player.model.position); | |
| } | |
| 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) { | |
| switch (event.key.toLowerCase()) { | |
| 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; | |
| if (gameState.nearbyNpc && !gameState.isJumping) { | |
| showDialog(gameState.nearbyNpc); | |
| event.preventDefault(); // Prevent default space behavior | |
| } | |
| break; | |
| } | |
| } | |
| function handleKeyUp(event) { | |
| switch (event.key.toLowerCase()) { | |
| 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; | |
| } | |
| } | |
| </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> |