| <!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8" /> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" /> |
| <title>3D Garage Explorer</title> |
| <style> |
| body { |
| margin: 0; |
| overflow: hidden; |
| background: #000; |
| font-family: Arial, sans-serif; |
| } |
| canvas { |
| display: block; |
| width: 100%; |
| height: 100%; |
| } |
| #loading { |
| position: fixed; |
| top: 50%; |
| left: 50%; |
| transform: translate(-50%, -50%); |
| background: rgba(0, 0, 0, 0.8); |
| color: #fff; |
| padding: 20px 40px; |
| border-radius: 8px; |
| text-align: center; |
| z-index: 100; |
| font-size: 18px; |
| } |
| |
| #orientationWarning { |
| position: fixed; |
| top: 0; |
| left: 0; |
| width: 100%; |
| height: 100%; |
| background: rgba(0, 0, 0, 0.95); |
| color: #fff; |
| display: none; |
| justify-content: center; |
| align-items: center; |
| flex-direction: column; |
| z-index: 200; |
| font-size: 24px; |
| text-align: center; |
| padding: 20px; |
| } |
| |
| #orientationWarning.show { |
| display: flex; |
| } |
| |
| .rotate-icon { |
| font-size: 64px; |
| margin: 20px 0; |
| animation: rotate 2s infinite ease-in-out; |
| } |
| |
| @keyframes rotate { |
| 0%, 100% { transform: rotate(0deg); } |
| 50% { transform: rotate(90deg); } |
| } |
| |
| #joystick { |
| position: fixed; |
| bottom: 20px; |
| left: 20px; |
| width: 140px; |
| height: 140px; |
| background: radial-gradient(circle, rgba(50, 50, 50, 0.4), rgba(30, 30, 30, 0.6)); |
| border: 3px solid rgba(255, 255, 255, 0.3); |
| border-radius: 50%; |
| z-index: 50; |
| box-shadow: 0 0 20px rgba(0, 0, 0, 0.5); |
| } |
| |
| #joystickHandle { |
| position: absolute; |
| width: 50px; |
| height: 50px; |
| background: radial-gradient(circle, rgba(0, 150, 255, 0.8), rgba(0, 100, 200, 0.6)); |
| border: 3px solid rgba(255, 255, 255, 0.5); |
| border-radius: 50%; |
| top: 50%; |
| left: 50%; |
| transform: translate(-50%, -50%); |
| cursor: grab; |
| box-shadow: 0 0 15px rgba(0, 150, 255, 0.6); |
| transition: none; |
| will-change: transform; |
| } |
| |
| #joystickHandle:active { |
| cursor: grabbing; |
| box-shadow: 0 0 25px rgba(0, 150, 255, 1); |
| } |
| |
| .button-group { |
| position: fixed; |
| right: 20px; |
| bottom: 20px; |
| display: flex; |
| flex-direction: column; |
| gap: 12px; |
| z-index: 50; |
| } |
| |
| .control-btn { |
| padding: 16px 24px; |
| background: linear-gradient(135deg, rgba(0, 150, 255, 0.7), rgba(0, 100, 200, 0.7)); |
| border: 2px solid rgba(255, 255, 255, 0.3); |
| color: white; |
| border-radius: 12px; |
| cursor: pointer; |
| font-weight: bold; |
| font-size: 18px; |
| min-width: 90px; |
| box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3); |
| transition: all 0.1s ease; |
| user-select: none; |
| } |
| |
| .control-btn:active { |
| background: linear-gradient(135deg, rgba(0, 150, 255, 1), rgba(0, 100, 200, 1)); |
| transform: scale(0.95); |
| box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5); |
| } |
| |
| #coordinates { |
| position: fixed; |
| top: 10px; |
| right: 10px; |
| left: auto; |
| background: linear-gradient(135deg, rgba(0, 0, 0, 0.85), rgba(20, 20, 40, 0.85)); |
| color: #00ff88; |
| padding: 5px 8px; |
| border-radius: 6px; |
| font-family: 'Courier New', monospace; |
| font-size: 9px; |
| z-index: 50; |
| max-width: 120px; |
| border: 1px solid rgba(0, 255, 136, 0.3); |
| box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5); |
| } |
| |
| #fps { |
| position: fixed; |
| top: 10px; |
| left: 10px; |
| background: rgba(0, 0, 0, 0.7); |
| color: #00ff00; |
| padding: 4px 8px; |
| border-radius: 4px; |
| font-family: 'Courier New', monospace; |
| font-size: 11px; |
| z-index: 50; |
| border: 1px solid rgba(0, 255, 0, 0.3); |
| } |
| |
| #coordinates > div { |
| margin: 1px 0; |
| text-shadow: 0 0 5px rgba(0, 255, 136, 0.5); |
| } |
| |
| .coord-label { |
| color: #00ffff; |
| font-weight: bold; |
| font-size: 10px; |
| } |
| |
| .coord-input { |
| background: rgba(0, 30, 20, 0.9); |
| border: 1px solid #00ff88; |
| color: #00ff88; |
| padding: 4px; |
| border-radius: 3px; |
| font-family: 'Courier New', monospace; |
| margin-top: 4px; |
| width: calc(100% - 10px); |
| font-size: 8px; |
| } |
| |
| .spawn-btn { |
| background: linear-gradient(135deg, rgba(0, 180, 100, 0.8), rgba(0, 120, 80, 0.8)); |
| border: 1px solid #00ff88; |
| color: #fff; |
| padding: 4px; |
| border-radius: 3px; |
| margin-top: 4px; |
| cursor: pointer; |
| font-weight: bold; |
| width: 100%; |
| transition: all 0.1s ease; |
| user-select: none; |
| font-size: 9px; |
| } |
| |
| .spawn-btn:active { |
| transform: scale(0.97); |
| background: linear-gradient(135deg, rgba(0, 200, 110, 1), rgba(0, 140, 90, 1)); |
| } |
| |
| #crosshair { |
| position: fixed; |
| top: 50%; |
| left: 50%; |
| transform: translate(-50%, -50%); |
| width: 20px; |
| height: 20px; |
| z-index: 100; |
| pointer-events: none; |
| } |
| |
| .crosshair-line { |
| position: absolute; |
| background: rgba(255, 255, 255, 0.8); |
| } |
| |
| .crosshair-h { |
| width: 12px; |
| height: 2px; |
| top: 50%; |
| left: 50%; |
| transform: translate(-50%, -50%); |
| } |
| |
| .crosshair-v { |
| width: 2px; |
| height: 12px; |
| top: 50%; |
| left: 50%; |
| transform: translate(-50%, -50%); |
| } |
| |
| .crosshair-dot { |
| width: 3px; |
| height: 3px; |
| background: rgba(255, 255, 255, 0.9); |
| border-radius: 50%; |
| top: 50%; |
| left: 50%; |
| transform: translate(-50%, -50%); |
| } |
| </style> |
| </head> |
| <body> |
| <div id="orientationWarning"> |
| <div class="rotate-icon">📱</div> |
| <div>Please rotate your device to landscape mode</div> |
| <div style="font-size: 16px; margin-top: 10px; opacity: 0.7;">for the best experience</div> |
| </div> |
| |
| <div id="loading">Loading...</div> |
| |
| <div id="joystick"> |
| <div id="joystickHandle"></div> |
| </div> |
| |
| <div class="button-group"> |
| <button class="control-btn" id="upBtn">↑ Up</button> |
| <button class="control-btn" id="downBtn">↓ Down</button> |
| </div> |
| |
| <div id="coordinates"> |
| <div class="coord-label">POS</div> |
| <div>X: <span id="coordX">0.00</span></div> |
| <div>Y: <span id="coordY">0.00</span></div> |
| <div>Z: <span id="coordZ">0.00</span></div> |
| <input type="text" class="coord-input" id="spawnInput" placeholder="Spawn JSON"> |
| <button class="spawn-btn" onclick="loadSpawn()">Set</button> |
| </div> |
| |
| <div id="crosshair"> |
| <div class="crosshair-line crosshair-h"></div> |
| <div class="crosshair-line crosshair-v"></div> |
| <div class="crosshair-dot"></div> |
| </div> |
|
|
| <script type="importmap"> |
| { |
| "imports": { |
| "three": "https://cdn.jsdelivr.net/npm/three@0.128.0/build/three.module.js", |
| "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.128.0/examples/jsm/" |
| } |
| } |
| </script> |
|
|
| <script type="module"> |
| import * as THREE from 'three'; |
| import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'; |
| import { FBXLoader } from 'three/addons/loaders/FBXLoader.js'; |
| |
| let scene, camera, renderer, playerModel, garage; |
| let playerPos = { x: 0, y: 1, z: 0 }; |
| let moveSideways = 0; |
| let moveForwardBack = 0; |
| let moveSpeed = 0.06; |
| let cameraRotation = { x: 0, y: 0 }; |
| let spawnPoint = { x: -3.5719999999999996, y: 1, z: 0.20199999999999968 }; |
| let joystickActive = false; |
| let lookTouchId = null; |
| let loadingSteps = { character: false, garage: false }; |
| |
| |
| function checkOrientation() { |
| const warning = document.getElementById('orientationWarning'); |
| if (window.innerHeight > window.innerWidth) { |
| warning.classList.add('show'); |
| } else { |
| warning.classList.remove('show'); |
| } |
| } |
| |
| checkOrientation(); |
| window.addEventListener('resize', checkOrientation); |
| window.addEventListener('orientationchange', checkOrientation); |
| |
| function updateLoadingStatus() { |
| const allLoaded = loadingSteps.character && loadingSteps.garage; |
| if (allLoaded) { |
| document.getElementById('loading').style.display = 'none'; |
| } |
| } |
| |
| function init() { |
| scene = new THREE.Scene(); |
| scene.background = new THREE.Color(0x1a1a2e); |
| scene.fog = new THREE.Fog(0x1a1a2e, 5, 50); |
| |
| camera = new THREE.PerspectiveCamera( |
| 75, |
| window.innerWidth / window.innerHeight, |
| 0.01, |
| 50 |
| ); |
| |
| renderer = new THREE.WebGLRenderer({ antialias: true }); |
| renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); |
| renderer.setSize(window.innerWidth, window.innerHeight); |
| renderer.shadowMap.enabled = true; |
| document.body.appendChild(renderer.domElement); |
| |
| |
| scene.add(new THREE.AmbientLight(0xffffff, 0.7)); |
| |
| const dirLight1 = new THREE.DirectionalLight(0xffffff, 0.8); |
| dirLight1.position.set(20, 20, 20); |
| dirLight1.castShadow = true; |
| scene.add(dirLight1); |
| |
| const dirLight2 = new THREE.DirectionalLight(0xffffff, 0.6); |
| dirLight2.position.set(-20, 15, -20); |
| scene.add(dirLight2); |
| |
| const dirLight3 = new THREE.DirectionalLight(0xffffff, 0.5); |
| dirLight3.position.set(0, 20, -20); |
| scene.add(dirLight3); |
| |
| const pointLight1 = new THREE.PointLight(0xffffff, 0.5, 50); |
| pointLight1.position.set(10, 10, 10); |
| scene.add(pointLight1); |
| |
| const pointLight2 = new THREE.PointLight(0xffffff, 0.5, 50); |
| pointLight2.position.set(-10, 10, -10); |
| scene.add(pointLight2); |
| |
| const pointLight3 = new THREE.PointLight(0xffffff, 0.4, 50); |
| pointLight3.position.set(0, 5, 0); |
| scene.add(pointLight3); |
| |
| |
| const fbxLoader = new FBXLoader(); |
| document.getElementById('loading').textContent = 'Loading character...'; |
| |
| fbxLoader.load('./character.fbx', (fbx) => { |
| playerModel = new THREE.Group(); |
| |
| |
| fbx.position.set(0, 0, 0); |
| fbx.scale.set(0.01, 0.01, 0.01); |
| |
| |
| fbx.traverse((child) => { |
| if (child.isMesh) { |
| child.castShadow = true; |
| child.receiveShadow = true; |
| if (child.material) { |
| child.material.transparent = false; |
| child.material.opacity = 1; |
| child.material.side = THREE.DoubleSide; |
| } |
| } |
| }); |
| |
| playerModel.add(fbx); |
| playerModel.position.set(playerPos.x, playerPos.y, playerPos.z); |
| |
| scene.add(playerModel); |
| loadingSteps.character = true; |
| updateLoadingStatus(); |
| console.log('✅ Character loaded!'); |
| console.log('Character world position:', playerModel.position); |
| console.log('FBX local position:', fbx.position); |
| console.log('Camera position:', camera.position); |
| }, (xhr) => { |
| const percent = (xhr.loaded / xhr.total * 100).toFixed(1); |
| document.getElementById('loading').textContent = `Loading character ${percent}%`; |
| }, (error) => { |
| console.error('Error loading character:', error); |
| document.getElementById('loading').textContent = 'Error loading character.fbx'; |
| }); |
| |
| |
| const gltfLoader = new GLTFLoader(); |
| gltfLoader.load('./map.glb', (gltf) => { |
| garage = gltf.scene; |
| garage.scale.set(-1, 1, 1); |
| scene.add(garage); |
| |
| garage.traverse((child) => { |
| child.castShadow = true; |
| child.receiveShadow = true; |
| if (child.geometry) { |
| child.userData.isCollider = true; |
| } |
| }); |
| |
| playerPos.x = spawnPoint.x; |
| playerPos.y = spawnPoint.y; |
| playerPos.z = spawnPoint.z; |
| |
| loadingSteps.garage = true; |
| updateLoadingStatus(); |
| console.log('✅ Garage loaded!'); |
| }, undefined, (error) => { |
| console.error('Error loading garage:', error); |
| document.getElementById('loading').textContent = 'Error loading map.glb'; |
| }); |
| |
| setupControls(); |
| animate(); |
| |
| window.addEventListener('resize', () => { |
| camera.aspect = window.innerWidth / window.innerHeight; |
| camera.updateProjectionMatrix(); |
| renderer.setSize(window.innerWidth, window.innerHeight); |
| }); |
| } |
| |
| function setupControls() { |
| const joystick = document.getElementById('joystick'); |
| const joystickHandle = document.getElementById('joystickHandle'); |
| let joystickTouchId = null; |
| let lastLookPos = { x: 0, y: 0 }; |
| |
| joystick.addEventListener('touchstart', (e) => { |
| joystickActive = true; |
| joystickTouchId = e.touches[0].identifier; |
| e.preventDefault(); |
| e.stopPropagation(); |
| }); |
| |
| document.addEventListener('touchmove', (e) => { |
| e.preventDefault(); |
| |
| for (let i = 0; i < e.touches.length; i++) { |
| const touch = e.touches[i]; |
| |
| if (joystickTouchId !== null && touch.identifier === joystickTouchId) { |
| const rect = joystick.getBoundingClientRect(); |
| const centerX = rect.left + rect.width / 2; |
| const centerY = rect.top + rect.height / 2; |
| const dx = touch.clientX - centerX; |
| const dy = touch.clientY - centerY; |
| const distance = Math.sqrt(dx * dx + dy * dy); |
| const maxDistance = 60; |
| |
| if (distance > 5) { |
| const limitedDistance = Math.min(distance, maxDistance); |
| const normalizedDx = (dx / distance) * limitedDistance; |
| const normalizedDy = (dy / distance) * limitedDistance; |
| |
| joystickHandle.style.transform = `translate(calc(-50% + ${normalizedDx}px), calc(-50% + ${normalizedDy}px))`; |
| |
| moveForwardBack = -(dy / maxDistance); |
| moveSideways = -(dx / maxDistance); |
| } else { |
| joystickHandle.style.transform = 'translate(-50%, -50%)'; |
| moveForwardBack = 0; |
| moveSideways = 0; |
| } |
| } |
| |
| if (touch.identifier !== joystickTouchId) { |
| if (lookTouchId === null) { |
| const joystickRect = joystick.getBoundingClientRect(); |
| const coordsRect = document.getElementById('coordinates').getBoundingClientRect(); |
| |
| const notTouchingJoystick = !(touch.clientX >= joystickRect.left - 30 && |
| touch.clientX <= joystickRect.right + 30 && |
| touch.clientY >= joystickRect.top - 30 && |
| touch.clientY <= joystickRect.bottom + 30); |
| |
| const notTouchingCoords = !(touch.clientX >= coordsRect.left - 10 && |
| touch.clientX <= coordsRect.right + 10 && |
| touch.clientY >= coordsRect.top - 10 && |
| touch.clientY <= coordsRect.bottom + 10); |
| |
| if (notTouchingJoystick && notTouchingCoords && touch.clientX < window.innerWidth * 0.85) { |
| lookTouchId = touch.identifier; |
| lastLookPos = { x: touch.clientX, y: touch.clientY }; |
| } |
| } else if (touch.identifier === lookTouchId) { |
| const deltaX = touch.clientX - lastLookPos.x; |
| const deltaY = touch.clientY - lastLookPos.y; |
| |
| cameraRotation.y -= deltaX * 0.012; |
| cameraRotation.x -= deltaY * 0.012; |
| cameraRotation.x = Math.max(-Math.PI / 2.5, Math.min(Math.PI / 2.5, cameraRotation.x)); |
| |
| lastLookPos = { x: touch.clientX, y: touch.clientY }; |
| } |
| } |
| } |
| }, { passive: false }); |
| |
| document.addEventListener('touchend', (e) => { |
| const remainingTouchIds = Array.from(e.touches).map(t => t.identifier); |
| |
| if (joystickTouchId !== null && !remainingTouchIds.includes(joystickTouchId)) { |
| joystickActive = false; |
| joystickTouchId = null; |
| joystickHandle.style.transform = 'translate(-50%, -50%)'; |
| moveForwardBack = 0; |
| moveSideways = 0; |
| } |
| |
| if (lookTouchId !== null && !remainingTouchIds.includes(lookTouchId)) { |
| lookTouchId = null; |
| } |
| }); |
| |
| document.getElementById('upBtn').addEventListener('touchstart', (e) => { |
| e.preventDefault(); |
| e.stopPropagation(); |
| playerPos.y += 0.5; |
| }); |
| |
| document.getElementById('downBtn').addEventListener('touchstart', (e) => { |
| e.preventDefault(); |
| e.stopPropagation(); |
| playerPos.y = Math.max(0.5, playerPos.y - 0.5); |
| }); |
| } |
| |
| function updateCoordinates() { |
| document.getElementById('coordX').textContent = playerPos.x.toFixed(2); |
| document.getElementById('coordY').textContent = playerPos.y.toFixed(2); |
| document.getElementById('coordZ').textContent = playerPos.z.toFixed(2); |
| } |
| |
| function checkCollisions(newPos) { |
| if (!garage) return newPos; |
| const playerRadius = 0.15; |
| |
| const raycaster = new THREE.Raycaster(); |
| const directions = [ |
| new THREE.Vector3(1, 0, 0), |
| new THREE.Vector3(-1, 0, 0), |
| new THREE.Vector3(0, 0, 1), |
| new THREE.Vector3(0, 0, -1), |
| ]; |
| |
| const origin = new THREE.Vector3(newPos.x, newPos.y + 0.25, newPos.z); |
| let blocked = false; |
| |
| for (const dir of directions) { |
| raycaster.set(origin, dir); |
| const intersects = raycaster.intersectObjects(garage.children, true); |
| |
| if (intersects.length > 0 && intersects[0].distance < playerRadius) { |
| blocked = true; |
| break; |
| } |
| } |
| |
| if (!blocked) { |
| return newPos; |
| } |
| |
| const tryX = { x: newPos.x, y: playerPos.y, z: playerPos.z }; |
| const tryZ = { x: playerPos.x, y: playerPos.y, z: newPos.z }; |
| |
| let xBlocked = false; |
| const originX = new THREE.Vector3(tryX.x, tryX.y + 0.25, tryX.z); |
| for (const dir of directions) { |
| raycaster.set(originX, dir); |
| const intersects = raycaster.intersectObjects(garage.children, true); |
| if (intersects.length > 0 && intersects[0].distance < playerRadius) { |
| xBlocked = true; |
| break; |
| } |
| } |
| |
| let zBlocked = false; |
| const originZ = new THREE.Vector3(tryZ.x, tryZ.y + 0.25, tryZ.z); |
| for (const dir of directions) { |
| raycaster.set(originZ, dir); |
| const intersects = raycaster.intersectObjects(garage.children, true); |
| if (intersects.length > 0 && intersects[0].distance < playerRadius) { |
| zBlocked = true; |
| break; |
| } |
| } |
| |
| if (!xBlocked) return tryX; |
| if (!zBlocked) return tryZ; |
| |
| return playerPos; |
| } |
| |
| function animate() { |
| requestAnimationFrame(animate); |
| |
| const forwardX = -Math.sin(cameraRotation.y) * moveForwardBack * moveSpeed; |
| const forwardZ = -Math.cos(cameraRotation.y) * moveForwardBack * moveSpeed; |
| |
| const strafeX = -Math.sin(cameraRotation.y + Math.PI / 2) * moveSideways * moveSpeed; |
| const strafeZ = -Math.cos(cameraRotation.y + Math.PI / 2) * moveSideways * moveSpeed; |
| |
| const newPos = { |
| x: playerPos.x + forwardX + strafeX, |
| y: playerPos.y, |
| z: playerPos.z + forwardZ + strafeZ |
| }; |
| |
| playerPos = checkCollisions(newPos); |
| |
| if (playerModel) { |
| playerModel.position.set(playerPos.x, playerPos.y, playerPos.z); |
| |
| playerModel.rotation.y = cameraRotation.y; |
| } |
| |
| |
| const eyeHeight = 1.6; |
| |
| camera.position.set( |
| playerPos.x, |
| playerPos.y + eyeHeight, |
| playerPos.z |
| ); |
| |
| camera.rotation.order = 'YXZ'; |
| camera.rotation.y = cameraRotation.y; |
| camera.rotation.x = cameraRotation.x - 0.5; |
| |
| updateCoordinates(); |
| renderer.render(scene, camera); |
| } |
| |
| window.loadSpawn = function() { |
| try { |
| const input = document.getElementById('spawnInput').value; |
| const coords = JSON.parse(input); |
| playerPos.x = coords.x || 0; |
| playerPos.y = coords.y || 1; |
| playerPos.z = coords.z || 0; |
| alert('✅ Spawn point set!'); |
| } catch (e) { |
| alert('❌ Invalid JSON format'); |
| } |
| }; |
| |
| window.copySpawn = function() { |
| const spawnData = JSON.stringify(playerPos); |
| navigator.clipboard.writeText(spawnData); |
| alert('✅ Copied to clipboard: ' + spawnData); |
| }; |
| |
| const coordsDiv = document.getElementById('coordinates'); |
| const copyBtn = document.createElement('button'); |
| copyBtn.className = 'spawn-btn'; |
| copyBtn.textContent = 'Copy Spawn'; |
| copyBtn.onclick = window.copySpawn; |
| coordsDiv.appendChild(copyBtn); |
| |
| init(); |
| </script> |
| </body> |
| </html> |