Spaces:
Running
Running
| <html> | |
| <head> | |
| <title>First Person Terrain Walker with Sky</title> | |
| <style> | |
| body { margin: 0; padding: 0; background: black; } | |
| canvas { display: block; } | |
| #instructions { | |
| position: fixed; | |
| top: 12px; | |
| left: 12px; | |
| background: rgba(0,0,0,0.4); | |
| color: white; | |
| padding: 10px; | |
| font-family: Arial, sans-serif; | |
| border-radius: 8px; | |
| } | |
| #downloadTexture { | |
| display: block; | |
| margin-top: 10px; | |
| padding: 5px 10px; | |
| background: #4CAF50; | |
| color: white; | |
| border: none; | |
| border-radius: 4px; | |
| cursor: pointer; | |
| font-family: Arial, sans-serif; | |
| } | |
| #downloadTexture:hover { | |
| background: #45a049; | |
| } | |
| #downloadTexture:disabled { | |
| background: #cccccc; | |
| cursor: not-allowed; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="instructions"> | |
| WASD or Arrow Keys - Move<br> | |
| Space - Jump<br> | |
| Mouse - Look around<br> | |
| Click to start<br> | |
| <button id="downloadTexture" disabled>Download Texture</button> | |
| </div> | |
| <script type="importmap"> | |
| { | |
| "imports": { | |
| "three": "https://cdnjs.cloudflare.com/ajax/libs/three.js/0.160.0/three.module.min.js", | |
| "three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/", | |
| "three/examples/": "https://unpkg.com/three@0.160.0/examples/jsm/" | |
| } | |
| } | |
| </script> | |
| <script type="module"> | |
| import * as THREE from 'three'; | |
| import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'; | |
| import { PointerLockControls } from 'three/examples/controls/PointerLockControls.js'; | |
| import { Sky } from 'three/addons/objects/Sky.js'; | |
| import { Lensflare, LensflareElement } from 'three/addons/objects/Lensflare.js'; | |
| const USE_SPARSE = true; | |
| // Scene setup | |
| const scene = new THREE.Scene(); | |
| const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000); | |
| const renderer = new THREE.WebGLRenderer({ antialias: true }); | |
| renderer.shadowMap.enabled = true; | |
| renderer.shadowMap.type = THREE.PCFSoftShadowMap; | |
| renderer.setSize(window.innerWidth, window.innerHeight); | |
| renderer.toneMapping = THREE.ACESFilmicToneMapping; | |
| renderer.toneMappingExposure = 0.5; | |
| document.body.appendChild(renderer.domElement); | |
| // Weapon sway parameters | |
| let currentWeaponSway = { x: 0, y: 0, z: 0 }; | |
| let targetWeaponSway = { x: 0, y: 0, z: 0 }; | |
| const maxSwayAmount = 0.03; | |
| const swaySpeed = 0.1; | |
| const swayLerpFactor = 0.1; | |
| let lastSwayUpdate = 0; | |
| const swayUpdateInterval = 150; // milliseconds | |
| // Download button setup | |
| const downloadButton = document.getElementById('downloadTexture'); | |
| let extractedTexture = null; | |
| // Function to extract and download texture | |
| function extractAndSaveTexture(mesh) { | |
| if (!mesh.material || !mesh.material.map) { | |
| console.warn('No texture found on this mesh'); | |
| return null; | |
| } | |
| const texture = mesh.material.map; | |
| const canvas = document.createElement('canvas'); | |
| canvas.width = texture.image.width; | |
| canvas.height = texture.image.height; | |
| const ctx = canvas.getContext('2d'); | |
| ctx.drawImage(texture.image, 0, 0); | |
| return canvas.toDataURL('image/png'); | |
| } | |
| // Download button click handler | |
| downloadButton.addEventListener('click', () => { | |
| if (extractedTexture) { | |
| const link = document.createElement('a'); | |
| link.href = extractedTexture; | |
| link.download = 'terrain_texture.png'; | |
| document.body.appendChild(link); | |
| link.click(); | |
| document.body.removeChild(link); | |
| } | |
| }); | |
| // Sky setup | |
| const sky = new Sky(); | |
| sky.scale.setScalar(450000); | |
| scene.add(sky); | |
| const skyUniforms = sky.material.uniforms; | |
| skyUniforms['turbidity'].value = 10; | |
| skyUniforms['rayleigh'].value = 3; | |
| skyUniforms['mieCoefficient'].value = 0.005; | |
| skyUniforms['mieDirectionalG'].value = 0.7; | |
| const sun = new THREE.Vector3(); | |
| const phi = THREE.MathUtils.degToRad(90 - 2); | |
| const theta = THREE.MathUtils.degToRad(180); | |
| sun.setFromSphericalCoords(1, phi, theta); | |
| skyUniforms['sunPosition'].value.copy(sun); | |
| // Lighting Setup | |
| const ambientLight = new THREE.AmbientLight(0xfffdfd, 0.6); | |
| scene.add(ambientLight); | |
| const sunLight = new THREE.DirectionalLight(0xfffdfd, USE_SPARSE ? 0.9 : 3.0); | |
| sunLight.position.set(50, 500, 50); | |
| sunLight.castShadow = true; | |
| sunLight.shadow.mapSize.width = 4096; | |
| sunLight.shadow.mapSize.height = 4096; | |
| sunLight.shadow.camera.near = 0.1; | |
| sunLight.shadow.camera.far = 2000; | |
| sunLight.shadow.camera.left = -500; | |
| sunLight.shadow.camera.right = 500; | |
| sunLight.shadow.camera.top = 500; | |
| sunLight.shadow.camera.bottom = -500; | |
| sunLight.shadow.bias = -0.0001; | |
| scene.add(sunLight); | |
| // Add Lensflare | |
| const textureLoader = new THREE.TextureLoader(); | |
| const textureFlare0 = textureLoader.load('/public/textures/lensflare/lensflare0_alpha.png'); | |
| const textureFlare1 = textureLoader.load('/public/textures/lensflare/lensflare3.png'); | |
| const textureFlare2 = textureLoader.load('/public/textures/lensflare/lensflare3.png'); | |
| const lensflare = new Lensflare(); | |
| lensflare.addElement(new LensflareElement(textureFlare0, 700, 0)); | |
| lensflare.addElement(new LensflareElement(textureFlare1, 512, 0.6)); | |
| lensflare.addElement(new LensflareElement(textureFlare2, 170, 0.7)); | |
| lensflare.addElement(new LensflareElement(textureFlare2, 120, 0.9)); | |
| sunLight.add(lensflare); | |
| const fillLight = new THREE.DirectionalLight(0xffffff, 1.2); | |
| fillLight.position.set(-50, 50, -50); | |
| scene.add(fillLight); | |
| const rimLight = new THREE.DirectionalLight(0xffffff, 0.8); | |
| rimLight.position.set(0, 20, -100); | |
| scene.add(rimLight); | |
| // Controls setup | |
| const controls = new PointerLockControls(camera, document.body); | |
| document.addEventListener('click', function() { | |
| controls.lock(); | |
| }); | |
| // Movement | |
| let moveForward = false; | |
| let moveBackward = false; | |
| let moveLeft = false; | |
| let moveRight = false; | |
| let canJump = false; | |
| const onKeyDown = function(event) { | |
| switch(event.code) { | |
| case 'KeyW': | |
| case 'ArrowUp': | |
| moveForward = true; | |
| break; | |
| case 'KeyA': | |
| case 'ArrowLeft': | |
| moveLeft = true; | |
| break; | |
| case 'KeyS': | |
| case 'ArrowDown': | |
| moveBackward = true; | |
| break; | |
| case 'KeyD': | |
| case 'ArrowRight': | |
| moveRight = true; | |
| break; | |
| case 'Space': | |
| if (canJump) { | |
| verticalVelocity = jumpForce; | |
| canJump = false; | |
| } | |
| break; | |
| } | |
| }; | |
| const onKeyUp = function(event) { | |
| switch(event.code) { | |
| case 'KeyW': | |
| case 'ArrowUp': | |
| moveForward = false; | |
| break; | |
| case 'KeyA': | |
| case 'ArrowLeft': | |
| moveLeft = false; | |
| break; | |
| case 'KeyS': | |
| case 'ArrowDown': | |
| moveBackward = false; | |
| break; | |
| case 'KeyD': | |
| case 'ArrowRight': | |
| moveRight = false; | |
| break; | |
| } | |
| }; | |
| document.addEventListener('keydown', onKeyDown); | |
| document.addEventListener('keyup', onKeyUp); | |
| // Physics variables | |
| const gravity = -35; | |
| let verticalVelocity = 0; | |
| const playerHeight = 2; | |
| const jumpForce = 14; | |
| const groundErrorMargin = 0.2; // Added error margin for ground detection | |
| let isGrounded = false; | |
| const bounceCoefficient = 0.4; | |
| // Raycaster for ground detection | |
| const raycaster = new THREE.Raycaster(); | |
| // Modified terrain loading with texture extraction | |
| const loader = new GLTFLoader(); | |
| let terrain; | |
| // Gun model setup | |
| let gunModel; | |
| const gunLoader = new GLTFLoader(); | |
| gunLoader.load('/public/models/laser-gun.glb', (gltf) => { | |
| // Scale and position the gun relative to camera | |
| gunModel = gltf.scene; | |
| gunModel.scale.set(0.3, 0.3, 0.3); | |
| gunModel.position.set(0.35, -0.23, -0.54); | |
| gunModel.rotation.y = Math.PI * 1.6; | |
| gunModel.rotation.z = Math.PI * 0.2; | |
| gunModel.rotation.x = Math.PI * 0.1 + currentWeaponSway.x; | |
| gunModel.rotation.y = Math.PI * 1.6 + currentWeaponSway.y; | |
| gunModel.rotation.z = Math.PI * 0.2 + currentWeaponSway.z; | |
| // Add the gun to the camera | |
| camera.add(gunModel); | |
| scene.add(camera); // Need to add camera to scene for gun to be visible | |
| }); | |
| loader.load('/public/models/pond-sparse.glb', (gltf) => { | |
| console.log("model loaded! preparing it.."); | |
| terrain = gltf.scene; | |
| terrain.scale.set(100, 100, 100); | |
| terrain.rotation.y = Math.PI; | |
| if (USE_SPARSE) { | |
| terrain.rotation.x = -Math.PI * 2.15; | |
| } | |
| let textureFound = false; | |
| terrain.traverse((node) => { | |
| if (node.isMesh) { | |
| node.castShadow = true; | |
| node.receiveShadow = true; | |
| if (node.material) { | |
| node.material.roughness = 0.8; | |
| node.material.metalness = 0.2; | |
| // Extract texture if available | |
| if (node.material.map && !textureFound) { | |
| extractedTexture = extractAndSaveTexture(node); | |
| if (extractedTexture) { | |
| downloadButton.disabled = false; | |
| textureFound = true; | |
| } | |
| } | |
| } | |
| } | |
| }); | |
| scene.add(terrain); | |
| const box = new THREE.Box3().setFromObject(terrain); | |
| const center = box.getCenter(new THREE.Vector3()); | |
| terrain.position.x -= center.x; | |
| terrain.position.z -= center.z; | |
| terrain.position.y = 0; | |
| raycaster.ray.origin.set(0, 100, 10); | |
| raycaster.ray.direction.set(0, -1, 0); | |
| const intersects = raycaster.intersectObjects(scene.children, true); | |
| if (intersects.length > 0) { | |
| camera.position.set(0, intersects[0].point.y + 2.5, 10); | |
| } else { | |
| camera.position.set(0, 20, 10); | |
| } | |
| camera.lookAt(new THREE.Vector3(0, 0, 0)); | |
| }, | |
| undefined, | |
| (error) => { | |
| console.error('An error happened:', error); | |
| const geometry = new THREE.PlaneGeometry(100, 100, 20, 20); | |
| const material = new THREE.MeshStandardMaterial({ | |
| color: 0x808080, | |
| roughness: 0.8, | |
| metalness: 0.2, | |
| wireframe: true | |
| }); | |
| terrain = new THREE.Mesh(geometry, material); | |
| terrain.castShadow = true; | |
| terrain.receiveShadow = true; | |
| scene.add(terrain); | |
| }); | |
| // Movement speed and physics | |
| const velocity = new THREE.Vector3(); | |
| const direction = new THREE.Vector3(); | |
| const speed = 0.5; | |
| const delta = 1/60; | |
| function checkGround() { | |
| if (!terrain) return { | |
| distance: Infinity, | |
| normal: new THREE.Vector3(0, 1, 0), | |
| hasHeadCollision: false | |
| }; | |
| // Ground check ray | |
| raycaster.ray.origin.copy(camera.position); | |
| raycaster.ray.direction.set(0, -1, 0); | |
| const groundIntersects = raycaster.intersectObjects(scene.children, true); | |
| // Head collision check ray | |
| raycaster.ray.origin.copy(camera.position); | |
| raycaster.ray.direction.set(0, 1, 0); | |
| const headIntersects = raycaster.intersectObjects(scene.children, true); | |
| const hasHeadCollision = headIntersects.length > 0 && headIntersects[0].distance < 1.0; | |
| if (groundIntersects.length > 0) { | |
| return { | |
| distance: groundIntersects[0].distance, | |
| normal: groundIntersects[0].face.normal.clone(), | |
| hasHeadCollision: hasHeadCollision | |
| }; | |
| } | |
| return { | |
| distance: Infinity, | |
| normal: new THREE.Vector3(0, 1, 0), | |
| hasHeadCollision: hasHeadCollision | |
| }; | |
| } | |
| function animate() { | |
| requestAnimationFrame(animate); | |
| if(controls.isLocked) { | |
| const groundInfo = checkGround(); | |
| const distanceToGround = groundInfo.distance; | |
| // Calculate current movement speed | |
| const currentSpeed = Math.sqrt(velocity.x * velocity.x + velocity.z * velocity.z); | |
| // Update weapon sway based on movement | |
| updateWeaponSway(currentSpeed); | |
| updateGunPosition(); | |
| // Check if we're below terrain or if there's no terrain below us | |
| raycaster.ray.origin.copy(camera.position); | |
| raycaster.ray.direction.set(0, -1, 0); | |
| const belowIntersects = raycaster.intersectObjects(scene.children, true); | |
| // Check if we're below terrain by casting a ray upward | |
| raycaster.ray.origin.copy(camera.position); | |
| raycaster.ray.direction.set(0, 1, 0); | |
| const aboveIntersects = raycaster.intersectObjects(scene.children, true); | |
| // If we're below terrain (ray hits something above us) or if there's no ground below us | |
| if ((aboveIntersects.length > 0 && aboveIntersects[0].distance < playerHeight) || | |
| belowIntersects.length === 0) { | |
| // Find a safe position above terrain | |
| raycaster.ray.origin.set(camera.position.x, 200, camera.position.z); | |
| raycaster.ray.direction.set(0, -1, 0); | |
| const rescueIntersects = raycaster.intersectObjects(scene.children, true); | |
| if (rescueIntersects.length > 0) { | |
| // Teleport player to safety | |
| camera.position.y = rescueIntersects[0].point.y + playerHeight; | |
| verticalVelocity = 0; | |
| isGrounded = true; | |
| canJump = true; | |
| } else { | |
| // If no safe position found, reset to initial position | |
| camera.position.set(0, 20, 10); | |
| verticalVelocity = 0; | |
| } | |
| } | |
| // Handle head collisions | |
| if (groundInfo.hasHeadCollision && verticalVelocity > 0) { | |
| verticalVelocity = 0; | |
| } | |
| const slopeAngle = Math.acos(groundInfo.normal.dot(new THREE.Vector3(0, 1, 0))); | |
| const maxClimbableAngle = Math.PI / 4; | |
| // Improved ground detection with error margin | |
| if (distanceToGround > playerHeight + groundErrorMargin) { | |
| verticalVelocity += gravity * delta; | |
| isGrounded = false; | |
| } else if (distanceToGround < playerHeight - groundErrorMargin) { | |
| if (verticalVelocity < 0) { | |
| verticalVelocity = Math.abs(verticalVelocity) * bounceCoefficient; | |
| camera.position.y = camera.position.y + (playerHeight - distanceToGround); | |
| } | |
| isGrounded = true; | |
| canJump = true; | |
| } else { | |
| if (Math.abs(verticalVelocity) < 0.1) { | |
| verticalVelocity = 0; | |
| isGrounded = true; | |
| canJump = true; | |
| } else { | |
| verticalVelocity *= 0.8; | |
| isGrounded = false; | |
| } | |
| } | |
| verticalVelocity = Math.max(verticalVelocity, -20); | |
| camera.position.y += verticalVelocity * delta; | |
| direction.z = Number(moveForward) - Number(moveBackward); | |
| direction.x = Number(moveRight) - Number(moveLeft); | |
| direction.normalize(); | |
| if(moveForward || moveBackward) velocity.z = -direction.z * speed; | |
| if(moveLeft || moveRight) velocity.x = -direction.x * speed; | |
| const moveDirection = new THREE.Vector3(-velocity.x, 0, -velocity.z).normalize(); | |
| const slopeDirection = groundInfo.normal.clone().projectOnPlane(new THREE.Vector3(0, 1, 0)).normalize(); | |
| const slopeFactor = 1.0 - (moveDirection.dot(slopeDirection) * (slopeAngle / (Math.PI / 2))); | |
| const slopeSpeedMultiplier = slopeFactor > 0 | |
| ? 1.0 - (Math.min(slopeFactor, 1.0) * 0.5) | |
| : 1.0 + (Math.min(Math.abs(slopeFactor), 1.0) * 0.3); | |
| const movementSpeed = isGrounded ? speed * slopeSpeedMultiplier : speed * 0.8; | |
| if (moveForward || moveBackward || moveLeft || moveRight) { | |
| controls.moveRight(-velocity.x * movementSpeed); | |
| controls.moveForward(-velocity.z * movementSpeed); | |
| } else { | |
| controls.moveRight(-velocity.x * movementSpeed); | |
| controls.moveForward(-velocity.z * movementSpeed); | |
| } | |
| velocity.x *= 0.9; | |
| velocity.z *= 0.9; | |
| } | |
| // Update sky and render | |
| const time = performance.now() * 0.0001; | |
| const distance = 400000; | |
| sun.x = distance * Math.cos(time); | |
| sun.y = distance * Math.sin(time) * 1.25; | |
| sun.z = distance * Math.sin(time) * 0.25; | |
| sky.material.uniforms['sunPosition'].value.copy(sun); | |
| sunLight.position.copy(sun).normalize().multiplyScalar(500); | |
| renderer.render(scene, camera); | |
| } | |
| function updateWeaponSway(currentSpeed) { | |
| const now = performance.now(); | |
| if (now - lastSwayUpdate < swayUpdateInterval) return; | |
| lastSwayUpdate = now; | |
| // Calculate sway amount based on movement speed | |
| const movementFactor = Math.min(currentSpeed / speed, 1); | |
| const swayAmount = maxSwayAmount * movementFactor; | |
| // Generate random sway targets | |
| targetWeaponSway.x = (Math.random() * 2 - 1) * swayAmount; | |
| targetWeaponSway.y = (Math.random() * 2 - 1) * swayAmount; | |
| targetWeaponSway.z = (Math.random() * 2 - 1) * swayAmount * 0.5; | |
| } | |
| function updateGunPosition() { | |
| if (!gunModel) return; | |
| // Interpolate current sway towards target | |
| currentWeaponSway.x += (targetWeaponSway.x - currentWeaponSway.x) * swayLerpFactor; | |
| currentWeaponSway.y += (targetWeaponSway.y - currentWeaponSway.y) * swayLerpFactor; | |
| currentWeaponSway.z += (targetWeaponSway.z - currentWeaponSway.z) * swayLerpFactor; | |
| // Apply sway to gun model's rotation | |
| gunModel.rotation.x = Math.PI * 0.1 + currentWeaponSway.x; | |
| gunModel.rotation.y = Math.PI * 1.6 + currentWeaponSway.y; | |
| gunModel.rotation.z = Math.PI * 0.2 + currentWeaponSway.z; | |
| } | |
| // Handle window resize | |
| window.addEventListener('resize', onWindowResize, false); | |
| function onWindowResize() { | |
| camera.aspect = window.innerWidth / window.innerHeight; | |
| camera.updateProjectionMatrix(); | |
| renderer.setSize(window.innerWidth, window.innerHeight); | |
| } | |
| animate(); | |
| </script> | |
| </body> | |
| </html> | |