Spaces:
Running
Running
| // ======================================================= | |
| // === Three.js 3D Initialization + Movimiento Avanzado === | |
| // ======================================================= | |
| let scene, camera, renderer, terrain, controls; | |
| let originalWormModel = null; | |
| let players = []; // Array con los jugadores { id, mesh, vel, grounded, bobTime } | |
| const keys = {}; | |
| const container = document.getElementById('game-container'); | |
| const textureLoader = new THREE.TextureLoader(); | |
| // Parámetros | |
| const movementSpeed = 0.7; | |
| const jumpSpeed = 10; | |
| const gravity = 28; | |
| const groundOffset = 5; // separación del gusano respecto a la superficie (no tocar tu modelo) | |
| const bobAmplitude = 0.25; | |
| const bobFrequency = 8; | |
| const clock = new THREE.Clock(); | |
| // ----------------------- | |
| // Util: obtener altura del terreno en (x,z) | |
| // ----------------------- | |
| function getTerrainHeight(x, z) { | |
| if (!terrain || !terrain.geometry) return 0; | |
| const geometry = terrain.geometry; | |
| const posAttr = geometry.attributes.position; | |
| const TERRAIN_SIZE = 150; // debe coincidir con createTerrain | |
| const HALF = TERRAIN_SIZE / 2; | |
| // Normalizar x,z a 0..1 | |
| const tx = (x + HALF) / TERRAIN_SIZE; | |
| const tz = (z + HALF) / TERRAIN_SIZE; | |
| // Clamp para evitar fuera del terreno | |
| const nx = Math.max(0, Math.min(1, tx)); | |
| const nz = Math.max(0, Math.min(1, tz)); | |
| const cols = Math.round(Math.sqrt(posAttr.count)); | |
| if (!cols || cols === 0) return 0; | |
| const ix = Math.floor(nx * (cols - 1)); | |
| const iz = Math.floor(nz * (cols - 1)); | |
| const index = iz * cols + ix; | |
| // Si por alguna razón index fuera de rango, devolver 0 | |
| if (index < 0 || index >= posAttr.count) return 0; | |
| return posAttr.getY(index); | |
| } | |
| // ----------------------- | |
| // Inicialización | |
| // ----------------------- | |
| function init() { | |
| scene = new THREE.Scene(); | |
| scene.background = new THREE.Color(0x87ceeb); | |
| camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000); | |
| camera.position.set(0, 50, 70); | |
| camera.lookAt(0, 0, 0); | |
| renderer = new THREE.WebGLRenderer({ antialias: true }); | |
| renderer.setSize(window.innerWidth, window.innerHeight); | |
| if (container) container.appendChild(renderer.domElement); | |
| else { console.error("No se encontró #game-container"); return; } | |
| const ambientLight = new THREE.AmbientLight(0x404040, 3); | |
| scene.add(ambientLight); | |
| const directionalLight = new THREE.DirectionalLight(0xffffff, 2); | |
| directionalLight.position.set(100, 100, 50); | |
| scene.add(directionalLight); | |
| createTerrain(); | |
| loadWormModel(); // cuando termine, clonará y creará players | |
| controls = new THREE.OrbitControls(camera, renderer.domElement); | |
| controls.enableDamping = true; | |
| controls.dampingFactor = 0.05; | |
| controls.minDistance = 20; | |
| controls.maxDistance = 120; | |
| window.addEventListener('keydown', onKeyDown); | |
| window.addEventListener('keyup', onKeyUp); | |
| window.addEventListener('resize', onWindowResize); | |
| } | |
| // ----------------------- | |
| // Terreno (procedural) — NO toca tu diseño, solo lo crea | |
| // ----------------------- | |
| function createTerrain() { | |
| const texture = textureLoader.load( | |
| 'grass_texture.jpg', | |
| tex => { | |
| tex.wrapS = THREE.RepeatWrapping; | |
| tex.wrapT = THREE.RepeatWrapping; | |
| tex.repeat.set(10, 10); | |
| }, | |
| undefined, | |
| err => console.warn('No cargó textura (CORS?)', err) | |
| ); | |
| const TERRAIN_SIZE = 150; | |
| const SEGMENTS = 128; // resolución (más alto = más preciso para alturas) | |
| const geometry = new THREE.PlaneGeometry(TERRAIN_SIZE, TERRAIN_SIZE, SEGMENTS, SEGMENTS); | |
| geometry.rotateX(-Math.PI / 2); | |
| const vertices = geometry.attributes.position.array; | |
| const MAX_ELEVATION = 15; | |
| for (let i = 0; i < vertices.length; i += 3) { | |
| const x = vertices[i] / TERRAIN_SIZE; | |
| const y = vertices[i + 2] / TERRAIN_SIZE; | |
| const elevation = | |
| (Math.sin(x * 10) * 0.5 + | |
| Math.cos(y * 7) * 0.8 + | |
| Math.sin((x + y) * 5) * 0.3) * MAX_ELEVATION; | |
| vertices[i + 1] = elevation; | |
| } | |
| geometry.computeVertexNormals(); | |
| terrain = new THREE.Mesh( | |
| geometry, | |
| new THREE.MeshPhongMaterial({ map: texture, side: THREE.DoubleSide }) | |
| ); | |
| scene.add(terrain); | |
| } | |
| // ----------------------- | |
| // Cargar modelo OBJ (una sola vez) y crear dos jugadores clonados | |
| // ----------------------- | |
| function loadWormModel() { | |
| const objLoader = new THREE.OBJLoader(); | |
| objLoader.load( | |
| 'cascabel123.obj', | |
| function (object) { | |
| // Guardar el modelo original (sin añadirlo a escena) | |
| originalWormModel = object; | |
| // Material de prueba pero respetando texturas si existieran | |
| object.traverse(child => { | |
| if (child.type === 'Mesh') { | |
| // Si ya tiene material, lo dejamos; si no, aplicamos uno suave | |
| if (!child.material) { | |
| child.material = new THREE.MeshPhongMaterial({ | |
| color: 0x68e079, | |
| specular: 0x555555, | |
| shininess: 30 | |
| }); | |
| } | |
| } | |
| }); | |
| // Crear players: jugador 1 y jugador 2 | |
| createPlayerFromModel('player1', -10, 0, 0); | |
| createPlayerFromModel('player2', 10, 0, 0); | |
| console.log("Modelo cargado y jugadores creados."); | |
| }, | |
| undefined, | |
| function (error) { | |
| console.error('Error cargando cascabel123.obj', error); | |
| } | |
| ); | |
| } | |
| function createPlayerFromModel(id, startX = 0, startZ = 0, startYOffset = 5) { | |
| if (!originalWormModel) { | |
| console.warn("Modelo aún no cargado; reintentar después."); | |
| return; | |
| } | |
| // Clonar profundamente para tener dos instancias independientes | |
| const clone = originalWormModel.clone(true); | |
| // Garantizar materiales en meshes (por si no se clonaron) | |
| clone.traverse(child => { | |
| if (child.type === 'Mesh') { | |
| if (!child.material) { | |
| child.material = new THREE.MeshPhongMaterial({ | |
| color: 0x68e079, | |
| specular: 0x555555, | |
| shininess: 30 | |
| }); | |
| } | |
| child.castShadow = true; | |
| child.receiveShadow = true; | |
| } | |
| }); | |
| // Ajustes iniciales sin tocar diseño visible | |
| clone.scale.set(10, 10, 10); | |
| const x = startX, z = startZ; | |
| const terrainH = getTerrainHeight(x, z); | |
| clone.position.set(x, terrainH + groundOffset + startYOffset*0, z); | |
| scene.add(clone); | |
| // Añadir al array de players | |
| players.push({ | |
| id, | |
| mesh: clone, | |
| vel: new THREE.Vector3(0, 0, 0), | |
| grounded: false, | |
| bobTime: 0 | |
| }); | |
| } | |
| // ----------------------- | |
| // Teclado robusto (mapea Space y Enter también) | |
| // ----------------------- | |
| function onKeyDown(e) { | |
| // e.key a minúsculas; e.code para espacios/enter | |
| const k = (e.key || '').toLowerCase(); | |
| if (k) keys[k] = true; | |
| if (e.code === 'Space') keys['space'] = true; | |
| if (e.code === 'Enter') keys['enter'] = true; | |
| // Flechas vienen como 'ArrowUp' etc. mapear a 'arrowup' | |
| if (e.key && e.key.startsWith('Arrow')) keys[e.key.toLowerCase()] = true; | |
| } | |
| function onKeyUp(e) { | |
| const k = (e.key || '').toLowerCase(); | |
| if (k) keys[k] = false; | |
| if (e.code === 'Space') keys['space'] = false; | |
| if (e.code === 'Enter') keys['enter'] = false; | |
| if (e.key && e.key.startsWith('Arrow')) keys[e.key.toLowerCase()] = false; | |
| } | |
| // ----------------------- | |
| // Lógica de movimiento para ambos jugadores (WASD+Space y Flechas+Enter) | |
| // ----------------------- | |
| function handleWormMovement(delta) { | |
| if (!players.length) return; | |
| players.forEach((p, idx) => { | |
| const m = p.mesh; | |
| if (!m) return; | |
| const isPlayer1 = p.id === 'player1'; | |
| // Controles por jugador | |
| const forwardKey = isPlayer1 ? 'w' : 'arrowup'; | |
| const backKey = isPlayer1 ? 's' : 'arrowdown'; | |
| const leftKey = isPlayer1 ? 'a' : 'arrowleft'; | |
| const rightKey = isPlayer1 ? 'd' : 'arrowright'; | |
| const jumpKey = isPlayer1 ? 'space' : 'enter'; | |
| let moved = false; | |
| // Movimiento horizontal plano (X,Z) | |
| if (keys[forwardKey]) { | |
| m.position.z -= movementSpeed; | |
| m.rotation.y = 0; | |
| moved = true; | |
| } | |
| if (keys[backKey]) { | |
| m.position.z += movementSpeed; | |
| m.rotation.y = Math.PI; | |
| moved = true; | |
| } | |
| if (keys[leftKey]) { | |
| m.position.x -= movementSpeed; | |
| m.rotation.y = Math.PI / 2; | |
| moved = true; | |
| } | |
| if (keys[rightKey]) { | |
| m.position.x += movementSpeed; | |
| m.rotation.y = -Math.PI / 2; | |
| moved = true; | |
| } | |
| // Actualizar bob (animación simple) si se mueve | |
| if (moved) { | |
| p.bobTime += delta * bobFrequency; | |
| } else { | |
| p.bobTime = 0; // resetea la oscilación cuando está quieto | |
| } | |
| // Física vertical: gravedad y salto | |
| const terrainY = getTerrainHeight(m.position.x, m.position.z); | |
| const targetGroundY = terrainY + groundOffset; | |
| // Si toca salto y está en tierra, aplicar impulso | |
| if (keys[jumpKey] && p.grounded) { | |
| p.vel.y = jumpSpeed; | |
| p.grounded = false; | |
| // Evitar salto sostenido: limpiar la tecla para que no salte infinito mientras se mantiene pulsado | |
| keys[jumpKey] = false; | |
| if (jumpKey === 'space') keys['space'] = false; | |
| if (jumpKey === 'enter') keys['enter'] = false; | |
| } | |
| // Aplicar gravedad | |
| p.vel.y -= gravity * delta; | |
| m.position.y += p.vel.y * delta; | |
| // Comprobación de colisión con terreno | |
| if (m.position.y <= targetGroundY) { | |
| m.position.y = targetGroundY; | |
| p.vel.y = 0; | |
| p.grounded = true; | |
| } | |
| // Aplicar bob encima de la altura real para dar sensación de caminar | |
| const bob = (p.bobTime > 0) ? Math.sin(p.bobTime) * bobAmplitude : 0; | |
| m.position.y = targetGroundY + bob; | |
| // Si es jugador 1, mover cámara suavemente con su delta | |
| if (isPlayer1 && idx === 0) { | |
| // La cámara la movemos sutilmente para seguir | |
| // (se dejó una lógica simple: la cámara se desplaza con el jugador) | |
| // Para evitar "teletransporte", empujamos la cámara en el mismo delta que el player movió en XZ | |
| // deltaXZ aproximado: | |
| // No disponible aquí el oldPos, así que calculamos mínima corrección: mantener camera mirando al player | |
| // controls.target se actualizará en animate para que la cámara siga | |
| } | |
| }); | |
| // Ajuste de cámara: la cámara mira siempre al player1 | |
| const p1 = players.find(x => x.id === 'player1'); | |
| if (controls && p1 && p1.mesh) { | |
| controls.target.copy(p1.mesh.position); | |
| } | |
| } | |
| // ----------------------- | |
| // Loop y utilidades | |
| // ----------------------- | |
| function onWindowResize() { | |
| camera.aspect = window.innerWidth / window.innerHeight; | |
| camera.updateProjectionMatrix(); | |
| renderer.setSize(window.innerWidth, window.innerHeight); | |
| } | |
| function animate() { | |
| requestAnimationFrame(animate); | |
| const delta = clock.getDelta(); | |
| handleWormMovement(delta); | |
| // Actualizar controles (la target ya fue ajustada) | |
| if (controls) controls.update(); | |
| renderer.render(scene, camera); | |
| } | |
| // Inicializar | |
| window.addEventListener('DOMContentLoaded', () => { | |
| init(); | |
| animate(); | |
| }); |