Spaces:
Sleeping
Sleeping
| <html> | |
| <head> | |
| <title>Live World Builder</title> | |
| <style> | |
| body { margin: 0; overflow: hidden; background-color: #f0f2f6; font-family: sans-serif;} | |
| canvas { display: block; } | |
| #info { position: absolute; top: 10px; width: 100%; text-align: center; z-index: 100; display:block; color: #333; background-color: rgba(255,255,255,0.7); padding: 5px; border-radius: 5px;} | |
| #connection-status { position: absolute; bottom: 10px; left: 10px; background-color: rgba(0,0,0,0.5); color: white; padding: 3px 8px; border-radius: 3px; font-size: 0.8em; z-index: 100; } | |
| .dot { height: 10px; width: 10px; border-radius: 50%; display: inline-block; margin-right: 5px; } | |
| .red { background-color: #f44336; } | |
| .green { background-color: #4CAF50; } | |
| .orange { background-color: #ff9800; } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="info">Builder: <span id="username">User</span> | Tool: <span id="current-tool">None</span> | Click to Place | Right-Click to Delete</div> | |
| <div id="connection-status"><span id="ws-dot" class="dot red"></span>Disconnected</div> | |
| <script type="importmap"> | |
| { | |
| "imports": { | |
| "three": "https://unpkg.com/[email protected]/build/three.module.js", | |
| "three/addons/": "https://unpkg.com/[email protected]/examples/jsm/" | |
| } | |
| } | |
| </script> | |
| <script type="module"> | |
| import * as THREE from 'three'; | |
| let scene, camera, renderer, playerMesh; | |
| let raycaster, mouse; | |
| const keysPressed = {}; | |
| const playerSpeed = 0.15; | |
| // --- World State (Managed by Server via WebSocket) --- | |
| const worldObjects = new Map(); // Map<obj_id, THREE.Object3D> | |
| const groundMeshes = {}; // Map<gridKey, THREE.Mesh> | |
| const otherPlayers = new Map(); // Map<client_id, THREE.Mesh> for presence | |
| // --- WebSocket --- | |
| let socket = null; | |
| let connectionRetries = 0; | |
| const MAX_RETRIES = 5; | |
| let pingIntervalId = null; | |
| // --- Access State from Streamlit (Injected) --- | |
| const myUsername = window.USERNAME || `User_${Math.random().toString(36).substring(2, 6)}`; | |
| const websocketUrl = window.WEBSOCKET_URL || "ws://localhost:8765"; | |
| let selectedObjectType = window.SELECTED_OBJECT_TYPE || "None"; | |
| const plotWidth = window.PLOT_WIDTH || 50.0; | |
| const plotDepth = window.PLOT_DEPTH || 50.0; | |
| // NOTE: initialWorldObjects from injection is removed, state comes via WebSocket | |
| // --- Materials (Reusable) --- | |
| const groundMaterial = new THREE.MeshStandardMaterial({ color: 0x66aa66, roughness: 0.9, metalness: 0.1, side: THREE.DoubleSide }); | |
| const placeholderGroundMaterial = new THREE.MeshStandardMaterial({ color: 0x559955, roughness: 0.95, metalness: 0.1, side: THREE.DoubleSide }); | |
| const objectMaterials = { | |
| 'wood': new THREE.MeshStandardMaterial({ color: 0x8B4513, roughness: 0.9 }), | |
| 'leaf': new THREE.MeshStandardMaterial({ color: 0x228B22, roughness: 0.8 }), | |
| 'stone': new THREE.MeshStandardMaterial({ color: 0xaaaaaa, roughness: 0.8, metalness: 0.1 }), | |
| 'house_wall': new THREE.MeshStandardMaterial({ color: 0xffccaa, roughness: 0.8 }), | |
| 'house_roof': new THREE.MeshStandardMaterial({ color: 0xaa5533, roughness: 0.7 }), | |
| 'brick': new THREE.MeshStandardMaterial({ color: 0x9B4C43, roughness: 0.85 }), | |
| 'metal': new THREE.MeshStandardMaterial({ color: 0xcccccc, roughness: 0.4, metalness: 0.8 }), | |
| 'gem': new THREE.MeshStandardMaterial({ color: 0x4FFFFF, roughness: 0.1, metalness: 0.2, transparent: true, opacity: 0.8 }), | |
| 'light': new THREE.MeshBasicMaterial({ color: 0xFFFF88 }), | |
| 'cactus': new THREE.MeshStandardMaterial({ color: 0x558B2F, roughness: 0.8 }), | |
| 'mushroom_stem': new THREE.MeshStandardMaterial({ color: 0xF5F5DC, roughness: 0.9 }), | |
| 'mushroom_cap': new THREE.MeshStandardMaterial({ color: 0xB22222, roughness: 0.7 }), | |
| 'player_other': new THREE.MeshStandardMaterial({ color: 0x8888ff, roughness: 0.7 }) // Other players | |
| }; | |
| // --- Initialization --- | |
| function init() { | |
| console.log("JS Init: Starting scene setup..."); | |
| scene = new THREE.Scene(); | |
| scene.background = new THREE.Color(0xADD8E6); | |
| const aspect = window.innerWidth / window.innerHeight; | |
| camera = new THREE.PerspectiveCamera(60, aspect, 0.1, 5000); | |
| camera.position.set(plotWidth / 2, 15, plotDepth / 2 + 20); | |
| camera.lookAt(plotWidth/2, 0, plotDepth/2); | |
| scene.add(camera); | |
| setupLighting(); | |
| createGroundPlane(0, 0, false); | |
| setupPlayer(); | |
| raycaster = new THREE.Raycaster(); | |
| mouse = new THREE.Vector2(); | |
| renderer = new THREE.WebGLRenderer({ antialias: true }); | |
| renderer.setSize(window.innerWidth, window.innerHeight); | |
| renderer.shadowMap.enabled = true; | |
| renderer.shadowMap.type = THREE.PCFSoftShadowMap; | |
| document.body.appendChild(renderer.domElement); | |
| connectWebSocket(); // Initialize WebSocket Connection | |
| updateInfoPanel(); // Initial UI update | |
| // Event Listeners | |
| window.addEventListener('resize', onWindowResize, false); | |
| renderer.domElement.addEventListener('mousemove', onMouseMove, false); | |
| renderer.domElement.addEventListener('click', onDocumentClick, false); | |
| renderer.domElement.addEventListener('contextmenu', onRightClick, false); | |
| document.addEventListener('keydown', onKeyDown); | |
| document.addEventListener('keyup', onKeyUp); | |
| // --- Define global functions needed by Python --- | |
| window.teleportPlayer = teleportPlayer; | |
| window.updateSelectedObjectType = updateSelectedObjectType; | |
| // Removed loadWorldState and getWorldStateForSave as WS handles state now | |
| console.log(`JS Init: Setup complete for user: ${myUsername}. Connecting to ${websocketUrl}...`); | |
| animate(); | |
| } | |
| // --- WebSocket Logic --- | |
| function updateConnectionStatus(status) { | |
| const statusEl = document.getElementById('connection-status'); | |
| const dotEl = document.getElementById('ws-dot'); | |
| if (statusEl && dotEl) { | |
| let text = 'Disconnected'; | |
| dotEl.className = 'dot red'; // Default red | |
| if (status === 'connected') { text = 'Connected'; dotEl.className = 'dot green'; } | |
| else if (status === 'connecting') { text = 'Connecting...'; dotEl.className = 'dot orange'; } | |
| else if (status === 'error') { text = 'Connection Error'; } | |
| statusEl.innerHTML = `<span id="ws-dot" class="${dotEl.className}"></span>${text}`; | |
| } | |
| } | |
| function connectWebSocket() { | |
| console.log(`Attempting WebSocket connection (${connectionRetries + 1}/${MAX_RETRIES})...`); | |
| updateConnectionStatus('connecting'); | |
| socket = new WebSocket(websocketUrl); | |
| socket.onopen = () => { | |
| console.log("✅ WebSocket connection established."); | |
| updateConnectionStatus('connected'); | |
| connectionRetries = 0; | |
| // Start sending pings to keep connection alive | |
| if(pingIntervalId) clearInterval(pingIntervalId); | |
| pingIntervalId = setInterval(() => { | |
| sendWebSocketMessage('ping', {}); | |
| }, 30000); // Send ping every 30 seconds | |
| // Initial state requested/sent by server handler now | |
| }; | |
| socket.onmessage = (event) => { | |
| try { | |
| const data = JSON.parse(event.data); | |
| handleWebSocketMessage(data); | |
| } catch (e) { console.error("❌ Failed to parse WebSocket message:", event.data, e); } | |
| }; | |
| socket.onerror = (error) => { | |
| console.error("❌ WebSocket error:", error); | |
| updateConnectionStatus('error'); | |
| }; | |
| socket.onclose = (event) => { | |
| console.warn(`🔌 WebSocket connection closed. Code: ${event.code}, Clean: ${event.wasClean}`); | |
| updateConnectionStatus('disconnected'); | |
| if(pingIntervalId) clearInterval(pingIntervalId); | |
| pingIntervalId = null; | |
| socket = null; | |
| if (connectionRetries < MAX_RETRIES) { // Reconnect logic | |
| connectionRetries++; | |
| const delay = Math.pow(2, connectionRetries) * 1000; | |
| console.log(`Attempting reconnection in ${delay / 1000}s...`); | |
| updateConnectionStatus('connecting'); | |
| setTimeout(connectWebSocket, delay); | |
| } else { console.error("❌ WebSocket reconnection failed after max retries."); } | |
| }; | |
| } | |
| function sendWebSocketMessage(type, payload = {}) { | |
| if (socket && socket.readyState === WebSocket.OPEN) { | |
| // Include username in all outgoing messages automatically | |
| payload.username = myUsername; | |
| const message = JSON.stringify({ type, payload }); | |
| socket.send(message); | |
| } else { console.warn("⚠️ WebSocket not open. Message not sent:", type); } | |
| } | |
| function handleWebSocketMessage(data) { | |
| const { type, payload } = data; | |
| // console.log("Received WS message:", type, payload); // Verbose logging | |
| switch (type) { | |
| case "initial_state": | |
| console.log(`✨ Received initial world state with ${Object.keys(payload).length} objects.`); | |
| clearWorldObjects(); // Clear previous objects | |
| for (const obj_id in payload) { createAndPlaceObject(payload[obj_id]); } | |
| checkAndExpandGroundVisuals(); // Create initial ground visuals | |
| break; | |
| case "object_placed": | |
| console.log(`➕ Object placed by ${payload.username}: ${payload.object_data?.type}`); | |
| createAndPlaceObject(payload.object_data); // Add or update | |
| break; | |
| case "object_deleted": | |
| console.log(`➖ Object deleted by ${payload.username}: ${payload.obj_id}`); | |
| removeObjectById(payload.obj_id); | |
| break; | |
| case "user_join": console.log(`👋 User joined: ${payload.username} (${payload.id})`); break; | |
| case "user_leave": | |
| console.log(`🚪 User left: ${payload.username} (${payload.id})`); | |
| removeOtherPlayer(payload.id); // Remove visual representation | |
| break; | |
| case "player_moved": // Update visual representation of other players | |
| if(payload.id !== socket?.id) { // Don't update self visually | |
| updateOtherPlayer(payload.id, payload.username, payload.position, payload.rotation); | |
| } | |
| break; | |
| case "pong": break; // Ignore server pong reply | |
| default: console.warn("⚠️ Received unknown WebSocket message type:", type); | |
| } | |
| } | |
| // --- Other Player Visuals --- | |
| function addOrUpdateOtherPlayerMesh(id, username) { | |
| let playerViz = otherPlayers.get(id); | |
| if (!playerViz) { | |
| const geometry = new THREE.CapsuleGeometry(0.4, 0.8, 4, 8); | |
| const material = objectMaterials.player_other; | |
| playerViz = new THREE.Mesh(geometry, material); | |
| playerViz.name = `player_${username}_${id.substring(0,4)}`; | |
| playerViz.castShadow = true; | |
| otherPlayers.set(id, playerViz); | |
| scene.add(playerViz); | |
| console.log(`Added visual for player ${username} (${id})`); | |
| } | |
| return playerViz; | |
| } | |
| function updateOtherPlayer(id, username, position, rotation) { | |
| const playerViz = addOrUpdateOtherPlayerMesh(id, username); | |
| if(position) playerViz.position.set(position.x, position.y, position.z); | |
| if(rotation) playerViz.rotation.set(rotation._x, rotation._y, rotation._z, rotation._order || 'XYZ'); | |
| } | |
| function removeOtherPlayer(id) { | |
| if (otherPlayers.has(id)) { | |
| const mesh = otherPlayers.get(id); | |
| scene.remove(mesh); | |
| if(mesh.geometry) mesh.geometry.dispose(); | |
| otherPlayers.delete(id); | |
| console.log(`Removed visual for player ${id}`); | |
| } | |
| } | |
| // --- Standard Setup Functions --- | |
| function setupLighting() { | |
| scene.add(new THREE.AmbientLight(0xffffff, 0.7)); | |
| const dirLight = new THREE.DirectionalLight(0xffffff, 1.0); | |
| dirLight.position.set(80, 150, 100); dirLight.castShadow = true; | |
| dirLight.shadow.mapSize.width = 2048; dirLight.shadow.mapSize.height = 2048; | |
| dirLight.shadow.camera.near = 10; dirLight.shadow.camera.far = 400; | |
| dirLight.shadow.camera.left = -150; dirLight.shadow.camera.right = 150; | |
| dirLight.shadow.camera.top = 150; dirLight.shadow.camera.bottom = -150; | |
| dirLight.shadow.bias = -0.002; scene.add(dirLight); | |
| scene.add(new THREE.HemisphereLight( 0xADD8E6, 0x66aa66, 0.4 )); | |
| } | |
| function setupPlayer() { | |
| const playerGeo = new THREE.CapsuleGeometry(0.4, 0.8, 4, 8); | |
| const playerMat = new THREE.MeshStandardMaterial({ color: 0xff4400, roughness: 0.6, metalness: 0.1 }); | |
| playerMesh = new THREE.Mesh(playerGeo, playerMat); | |
| playerMesh.position.set(plotWidth / 2, 0.8, plotDepth / 2); | |
| playerMesh.castShadow = true; playerMesh.receiveShadow = false; | |
| playerMesh.name = "player_local"; scene.add(playerMesh); | |
| } | |
| function createGroundPlane(gridX, gridZ, isPlaceholder) { | |
| const gridKey = `${gridX}_${gridZ}`; if (groundMeshes[gridKey]) return groundMeshes[gridKey]; | |
| const material = isPlaceholder ? placeholderGroundMaterial : groundMaterial; | |
| const groundGeometry = new THREE.PlaneGeometry(plotWidth, plotDepth); | |
| const groundMesh = new THREE.Mesh(groundGeometry, material); | |
| groundMesh.rotation.x = -Math.PI / 2; | |
| groundMesh.position.set(gridX * plotWidth + plotWidth / 2.0, -0.05, gridZ * plotDepth + plotDepth / 2.0); | |
| groundMesh.receiveShadow = true; groundMesh.userData = { gridKey: gridKey, isPlaceholder: isPlaceholder }; | |
| groundMesh.name = `ground_${gridKey}`; scene.add(groundMesh); groundMeshes[gridKey] = groundMesh; return groundMesh; | |
| } | |
| // --- Object Creation / Management --- | |
| function clearWorldObjects() { | |
| console.log("JS: Clearing existing world objects..."); | |
| const idsToRemove = Array.from(worldObjects.keys()); | |
| idsToRemove.forEach(obj_id => removeObjectById(obj_id)); | |
| worldObjects.clear(); | |
| console.log("JS: World objects cleared."); | |
| } | |
| function removeObjectById(obj_id) { | |
| if (worldObjects.has(obj_id)) { | |
| const mesh = worldObjects.get(obj_id); | |
| scene.remove(mesh); | |
| if (mesh.geometry) mesh.geometry.dispose(); | |
| if (mesh.material) { if (Array.isArray(mesh.material)) mesh.material.forEach(m => m.dispose()); else mesh.material.dispose(); } | |
| worldObjects.delete(obj_id); | |
| console.log(`JS: Removed object ${obj_id}`); | |
| } | |
| } | |
| function createAndPlaceObject(objData) { // Renamed, removed bool flag | |
| if (!objData || !objData.obj_id || !objData.type) { console.warn("JS: Invalid object data:", objData); return null; } | |
| if (worldObjects.has(objData.obj_id)) { // Update existing | |
| const mesh = worldObjects.get(objData.obj_id); | |
| if (mesh.position.distanceToSquared(objData.position) > 0.001) { mesh.position.set(objData.position.x, objData.position.y, objData.position.z); } | |
| if (objData.rotation && (Math.abs(mesh.rotation.x - objData.rotation._x) > 0.01 || Math.abs(mesh.rotation.y - objData.rotation._y) > 0.01 || Math.abs(mesh.rotation.z - objData.rotation._z) > 0.01 )) { mesh.rotation.set(objData.rotation._x, objData.rotation._y, objData.rotation._z, objData.rotation._order || 'XYZ'); } | |
| return mesh; | |
| } else { // Create new | |
| let mesh = createPrimitiveMesh(objData.type); | |
| if (!mesh) return null; | |
| mesh.userData = { obj_id: objData.obj_id, type: objData.type }; | |
| mesh.name = `${objData.type}_${objData.obj_id.substring(0, 4)}`; | |
| if (objData.position) { mesh.position.set(objData.position.x, objData.position.y, objData.position.z); } | |
| if (objData.rotation) { mesh.rotation.set(objData.rotation._x, objData.rotation._y, objData.rotation._z, objData.rotation._order || 'XYZ'); } | |
| scene.add(mesh); | |
| worldObjects.set(objData.obj_id, mesh); | |
| return mesh; | |
| } | |
| } | |
| function createPrimitiveMesh(type) { // Keep factory as before | |
| let mesh = null; let geometry, material, material2; | |
| const wood = objectMaterials.wood; const leaf = objectMaterials.leaf; const stone = objectMaterials.stone; | |
| const house_wall = objectMaterials.house_wall; const house_roof = objectMaterials.house_roof; | |
| const brick = objectMaterials.brick; const metal = objectMaterials.metal; const gem = objectMaterials.gem; | |
| const lightMat = objectMaterials.light; const cactus = objectMaterials.cactus; | |
| const mushroom_stem = objectMaterials.mushroom_stem; const mushroom_cap = objectMaterials.mushroom_cap; | |
| try { | |
| switch(type) { | |
| case "Tree": mesh = new THREE.Group(); material = wood; material2 = leaf; const trunk = new THREE.Mesh(new THREE.CylinderGeometry(0.3, 0.4, 2, 8), material); trunk.position.y = 1; trunk.castShadow=true; trunk.receiveShadow=true; mesh.add(trunk); const canopy = new THREE.Mesh(new THREE.IcosahedronGeometry(1.2, 0), material2); canopy.position.y = 2.8; canopy.castShadow=true; canopy.receiveShadow=false; mesh.add(canopy); break; | |
| case "Rock": geometry = new THREE.IcosahedronGeometry(0.7, 1); material = stone; mesh = new THREE.Mesh(geometry, material); mesh.castShadow = true; mesh.receiveShadow = true; mesh.scale.set(1 + Math.random()*0.2-0.1, Math.random()*0.4 + 0.8, 1 + Math.random()*0.2-0.1); mesh.rotation.set(Math.random()*Math.PI, Math.random()*Math.PI, Math.random()*Math.PI); break; | |
| case "Simple House": mesh = new THREE.Group(); material = house_wall; material2 = house_roof; const body = new THREE.Mesh(new THREE.BoxGeometry(2, 1.5, 2.5), material); body.position.y = 0.75; body.castShadow = true; body.receiveShadow = true; mesh.add(body); const roof = new THREE.Mesh(new THREE.ConeGeometry(1.8, 1, 4), material2); roof.position.y = 1.5 + 0.5; roof.rotation.y = Math.PI / 4; roof.castShadow = true; roof.receiveShadow = false; mesh.add(roof); break; | |
| case "Fence Post": geometry = new THREE.BoxGeometry(0.2, 1.5, 0.2); material = wood; mesh = new THREE.Mesh(geometry, material); mesh.position.y = 0.75; mesh.castShadow = true; mesh.receiveShadow = true; break; | |
| case "Pine Tree": mesh = new THREE.Group(); material = wood; material2 = leaf; const pineTrunk = new THREE.Mesh(new THREE.CylinderGeometry(0.2, 0.3, 2.5, 8), material); pineTrunk.position.y = 1.25; pineTrunk.castShadow=true; pineTrunk.receiveShadow=true; mesh.add(pineTrunk); const pineCanopy = new THREE.Mesh(new THREE.ConeGeometry(1, 2.5, 8), material2); pineCanopy.position.y = 2.5; pineCanopy.castShadow=true; pineCanopy.receiveShadow=false; mesh.add(pineCanopy); break; | |
| case "Brick Wall": geometry = new THREE.BoxGeometry(3, 2, 0.3); material = brick; mesh = new THREE.Mesh(geometry, material); mesh.position.y = 1; mesh.castShadow = true; mesh.receiveShadow = true; break; | |
| case "Sphere": geometry = new THREE.SphereGeometry(0.8, 16, 12); material = metal; mesh = new THREE.Mesh(geometry, material); mesh.position.y = 0.8; mesh.castShadow = true; mesh.receiveShadow = true; break; | |
| case "Cube": geometry = new THREE.BoxGeometry(1, 1, 1); material = wood; mesh = new THREE.Mesh(geometry, material); mesh.position.y = 0.5; mesh.castShadow = true; mesh.receiveShadow = true; break; | |
| case "Cylinder": geometry = new THREE.CylinderGeometry(0.5, 0.5, 1.5, 16); material = stone; mesh = new THREE.Mesh(geometry, material); mesh.position.y = 0.75; mesh.castShadow = true; mesh.receiveShadow = true; break; | |
| case "Cone": geometry = new THREE.ConeGeometry(0.6, 1.2, 16); material = leaf; mesh = new THREE.Mesh(geometry, material); mesh.position.y = 0.6; mesh.castShadow = true; mesh.receiveShadow = true; break; | |
| case "Torus": geometry = new THREE.TorusGeometry(0.6, 0.2, 8, 24); material = metal; mesh = new THREE.Mesh(geometry, material); mesh.position.y = 0.7; mesh.castShadow = true; mesh.receiveShadow = true; mesh.rotation.x = Math.PI / 2; break; | |
| case "Mushroom": mesh = new THREE.Group(); material = mushroom_stem; material2 = mushroom_cap; const stem = new THREE.Mesh(new THREE.CylinderGeometry(0.15, 0.1, 0.6, 8), material); stem.position.y = 0.3; stem.castShadow = true; stem.receiveShadow = true; mesh.add(stem); const cap = new THREE.Mesh(new THREE.SphereGeometry(0.4, 16, 8, 0, Math.PI * 2, 0, Math.PI / 1.8), material2); cap.position.y = 0.6; cap.castShadow = true; cap.receiveShadow = false; mesh.add(cap); break; | |
| case "Cactus": mesh = new THREE.Group(); material = cactus; geometry = new THREE.CapsuleGeometry(0.3, 1.2, 4, 8); const main = new THREE.Mesh(geometry, material); main.position.y = (1.2/2) + 0.3; main.castShadow = true; main.receiveShadow = true; mesh.add(main); geometry = new THREE.CapsuleGeometry(0.2, 0.6, 4, 8); const arm1 = new THREE.Mesh(geometry, material); arm1.position.set(0.3, 1, 0); arm1.rotation.z = Math.PI / 4; arm1.castShadow = true; arm1.receiveShadow = true; mesh.add(arm1); const arm2 = new THREE.Mesh(geometry, material); arm2.position.set(-0.3, 0.8, 0); arm2.rotation.z = -Math.PI / 4; arm2.castShadow = true; arm2.receiveShadow = true; mesh.add(arm2); break; | |
| case "Campfire": mesh = new THREE.Group(); material = wood; material2 = lightMat; geometry = new THREE.CylinderGeometry(0.1, 0.1, 0.8, 5); const log1 = new THREE.Mesh(geometry, material); log1.rotation.z = Math.PI/2; log1.position.set(0, 0.1, 0.2); log1.castShadow=true; mesh.add(log1); const log2 = new THREE.Mesh(geometry, material); log2.rotation.z = Math.PI/2; log2.rotation.y = Math.PI/3; log2.position.set(0.15, 0.1, -0.15); log2.castShadow=true; mesh.add(log2); const log3 = new THREE.Mesh(geometry, material); log3.rotation.z = Math.PI/2; log3.rotation.y = -Math.PI/3; log3.position.set(-0.15, 0.1, -0.15); log3.castShadow=true; mesh.add(log3); geometry = new THREE.ConeGeometry(0.2, 0.5, 8); const flame = new THREE.Mesh(geometry, material2); flame.position.y = 0.35; mesh.add(flame); break; | |
| case "Star": geometry = new THREE.SphereGeometry(0.5, 5, 4); material = lightMat; mesh = new THREE.Mesh(geometry, material); mesh.position.y = 1; break; | |
| case "Gem": geometry = new THREE.OctahedronGeometry(0.6, 0); material = gem; mesh = new THREE.Mesh(geometry, material); mesh.position.y = 0.6; mesh.castShadow = true; mesh.receiveShadow = true; break; | |
| case "Tower": geometry = new THREE.CylinderGeometry(1, 1.2, 5, 8); material = stone; mesh = new THREE.Mesh(geometry, material); mesh.position.y = 2.5; mesh.castShadow = true; mesh.receiveShadow = true; break; | |
| case "Barrier": geometry = new THREE.BoxGeometry(2, 0.5, 0.5); material = metal; mesh = new THREE.Mesh(geometry, material); mesh.position.y = 0.25; mesh.castShadow = true; mesh.receiveShadow = true; break; | |
| case "Fountain": mesh = new THREE.Group(); material = stone; geometry = new THREE.CylinderGeometry(1.5, 1.5, 0.3, 16); const baseF = new THREE.Mesh(geometry, material); baseF.position.y = 0.15; baseF.receiveShadow=true; mesh.add(baseF); geometry = new THREE.CylinderGeometry(0.8, 0.8, 0.5, 16); const midF = new THREE.Mesh(geometry, material); midF.position.y = 0.3+0.25; midF.castShadow=true; mesh.add(midF); geometry = new THREE.CylinderGeometry(0.4, 0.4, 0.7, 16); const topF = new THREE.Mesh(geometry, material); topF.position.y = 0.8+0.35; topF.castShadow=true; mesh.add(topF); break; | |
| case "Lantern": mesh = new THREE.Group(); material = metal; material2 = lightMat; geometry = new THREE.BoxGeometry(0.4, 0.6, 0.4); const bodyL = new THREE.Mesh(geometry, material); bodyL.position.y = 0.3; bodyL.castShadow=true; mesh.add(bodyL); geometry = new THREE.SphereGeometry(0.15); const lightL = new THREE.Mesh(geometry, material2); lightL.position.y = 0.3; mesh.add(lightL); break; | |
| case "Sign Post": mesh = new THREE.Group(); material = wood; geometry = new THREE.CylinderGeometry(0.05, 0.05, 1.8, 8); const postS = new THREE.Mesh(geometry, material); postS.position.y = 0.9; postS.castShadow=true; postS.receiveShadow=true; mesh.add(postS); geometry = new THREE.BoxGeometry(0.8, 0.4, 0.05); const signS = new THREE.Mesh(geometry, material); signS.position.y = 1.5; signS.castShadow=true; signS.receiveShadow=true; mesh.add(signS); break; | |
| default: console.warn("JS: Unknown primitive type:", type); return null; | |
| } | |
| } catch (e) { console.error(`JS: Error creating mesh for ${type}:`, e); return null; } | |
| if (mesh) { mesh.userData = { type: type }; } // Set basic userdata | |
| return mesh; | |
| } | |
| // --- Event Handlers --- | |
| function onMouseMove(event) { | |
| const rect = renderer.domElement.getBoundingClientRect(); | |
| mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1; | |
| mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1; | |
| } | |
| function onDocumentClick(event) { // Left Click - Place Object | |
| // console.log("JS Click. Tool:", selectedObjectType); | |
| if (selectedObjectType === "None" || !selectedObjectType) return; | |
| raycaster.setFromCamera(mouse, camera); | |
| const grounds = Object.values(groundMeshes); | |
| const intersects = raycaster.intersectObjects(grounds); | |
| if (intersects.length > 0) { | |
| const intersectPoint = intersects[0].point; | |
| const newObjData = { | |
| obj_id: THREE.MathUtils.generateUUID(), type: selectedObjectType, | |
| position: { x: intersectPoint.x, y: 0, z: intersectPoint.z }, | |
| rotation: { _x: 0, _y: Math.random() * Math.PI * 2, _z: 0, _order: 'XYZ' } | |
| }; | |
| // Adjust Y - Needs refinement based on actual geometry origins/bounding boxes | |
| let yOffset = 0.5; // Default lift | |
| if (["Rock", "Sphere", "Gem"].includes(selectedObjectType)) yOffset = 0.4; | |
| else if (["Simple House", "Cylinder", "Torus", "Fence Post", "Barrier", "Lantern"].includes(selectedObjectType)) yOffset = 0.75; | |
| else if (["Tree", "Pine Tree", "Mushroom", "Cactus", "Campfire", "Sign Post"].includes(selectedObjectType)) yOffset = 0; // Group base likely at 0 | |
| else if (selectedObjectType === "Tower") yOffset = 2.5; | |
| else if (selectedObjectType === "Cone") yOffset = 0.6; | |
| else if (selectedObjectType === "Star") yOffset = 1.0; | |
| else if (selectedObjectType === "Fountain") yOffset = 0.15; | |
| newObjData.position.y = intersectPoint.y + yOffset + 0.01; // Add slight buffer | |
| // Optimistic local placement (object added by server message now) | |
| // createAndPlaceObject(newObjData); // Don't add locally first? Or do? Let's add locally for responsiveness. | |
| const tempMesh = createAndPlaceObject(newObjData); // Add locally & track | |
| if(tempMesh){ | |
| sendWebSocketMessage("place_object", { object_data: newObjData }); // Send to server | |
| } else { | |
| console.error("Failed to create mesh locally for placement.") | |
| } | |
| } | |
| } | |
| function onRightClick(event) { // Right Click - Delete Object | |
| event.preventDefault(); | |
| raycaster.setFromCamera(mouse, camera); | |
| const objectMeshes = Array.from(worldObjects.values()); | |
| const intersects = raycaster.intersectObjects(objectMeshes, true); | |
| if (intersects.length > 0) { | |
| let objectToDelete = intersects[0].object; | |
| while (objectToDelete.parent && objectToDelete.parent !== scene && objectToDelete.userData?.obj_id === undefined) { | |
| objectToDelete = objectToDelete.parent; // Find parent with ID | |
| } | |
| const objIdToDelete = objectToDelete.userData?.obj_id; | |
| if (objIdToDelete && worldObjects.has(objIdToDelete)) { | |
| console.log(`JS Requesting delete for: ${objIdToDelete}`); | |
| // Optimistic local removal | |
| removeObjectById(objIdToDelete); | |
| // Send delete request to server | |
| sendWebSocketMessage("delete_object", { obj_id: objIdToDelete }); | |
| // Log client-side deletion attempt | |
| try { streamlit_js_eval(`add_action_log('Requested Delete: ${objIdToDelete.substring(0,6)}...')`, key='js_delete_log'); } catch(e) {} | |
| } | |
| } | |
| } | |
| function onKeyDown(event) { keysPressed[event.code] = true; } | |
| function onKeyUp(event) { keysPressed[event.code] = false; } | |
| function onWindowResize() { camera.aspect = window.innerWidth / window.innerHeight; camera.updateProjectionMatrix(); renderer.setSize(window.innerWidth, window.innerHeight); } | |
| // --- Functions callable by Python --- | |
| function teleportPlayer(targetX, targetZ) { | |
| console.log(`JS teleportPlayer called: x=${targetX}, z=${targetZ}`); | |
| if (playerMesh) { | |
| playerMesh.position.set(targetX, playerMesh.position.y, targetZ); | |
| const offset = new THREE.Vector3(0, 15, 20); | |
| const targetPosition = playerMesh.position.clone().add(offset); | |
| camera.position.copy(targetPosition); camera.lookAt(playerMesh.position); | |
| } | |
| } | |
| function updateSelectedObjectType(newType) { | |
| console.log("JS updateSelectedObjectType received:", newType); | |
| selectedObjectType = newType; updateInfoPanel(); | |
| } | |
| function updateInfoPanel() { // Helper to update top UI | |
| const userEl = document.getElementById('username'); | |
| const toolEl = document.getElementById('current-tool'); | |
| if (userEl) userEl.textContent = myUsername; | |
| if (toolEl) toolEl.textContent = selectedObjectType || "None"; | |
| } | |
| // --- Animation Loop & Player Movement/Camera --- | |
| let lastPositionSendTime = 0; | |
| const positionUpdateInterval = 100; // Send position every 100ms | |
| function updatePlayerMovement() { | |
| if (!playerMesh) return; | |
| const moveDirection = new THREE.Vector3(0, 0, 0); | |
| if (keysPressed['KeyW'] || keysPressed['ArrowUp']) moveDirection.z -= 1; | |
| if (keysPressed['KeyS'] || keysPressed['ArrowDown']) moveDirection.z += 1; | |
| if (keysPressed['KeyA'] || keysPressed['ArrowLeft']) moveDirection.x -= 1; | |
| if (keysPressed['KeyD'] || keysPressed['ArrowRight']) moveDirection.x += 1; | |
| let positionChanged = false; | |
| if (moveDirection.lengthSq() > 0) { | |
| const forward = new THREE.Vector3(); camera.getWorldDirection(forward); forward.y = 0; forward.normalize(); | |
| const right = new THREE.Vector3().crossVectors(camera.up, forward).normalize(); | |
| const worldMove = new THREE.Vector3(); | |
| worldMove.add(forward.multiplyScalar(-moveDirection.z * playerSpeed)); | |
| worldMove.add(right.multiplyScalar(-moveDirection.x * playerSpeed)); | |
| playerMesh.position.add(worldMove); | |
| playerMesh.position.y = Math.max(playerMesh.position.y, 0.8); // Ground clamp | |
| checkAndExpandGroundVisuals(); // Check if new ground needed | |
| positionChanged = true; | |
| } | |
| // Send position update periodically if moved | |
| const now = Date.now(); | |
| if (positionChanged && now - lastPositionSendTime > positionUpdateInterval) { | |
| sendWebSocketMessage("player_position", { | |
| position: { x: playerMesh.position.x, y: playerMesh.position.y, z: playerMesh.position.z }, | |
| rotation: { _x: playerMesh.rotation.x, _y: playerMesh.rotation.y, _z: playerMesh.rotation.z, _order: playerMesh.rotation.order } | |
| }); | |
| lastPositionSendTime = now; | |
| } | |
| } | |
| function updateCamera() { | |
| if (!playerMesh) return; | |
| const offset = new THREE.Vector3(0, 12, 18); | |
| const targetPosition = playerMesh.position.clone().add(offset); | |
| camera.position.lerp(targetPosition, 0.08); | |
| camera.lookAt(playerMesh.position); | |
| } | |
| function animate() { | |
| requestAnimationFrame(animate); | |
| updatePlayerMovement(); // Includes ground check & position sending | |
| updateCamera(); | |
| renderer.render(scene, camera); | |
| } | |
| // --- Start --- | |
| init(); | |
| </script> | |
| </body> | |
| </html> |