// Controller page - handles keyboard input and publishes state to cloud backend // ───────────────────────────────────────────────────────────────────────────── // CLOUD SYNC — WebSocket connection to backend // Falls back gracefully if server is unreachable (local dev still works) // ───────────────────────────────────────────────────────────────────────────── const WS_URL = 'wss://api.bim.anttila.io/ws' let ws = null let wsReady = false let wsReconnectTimer = null const WS_RECONNECT_DELAY = 3000 function connectWS() { if (ws && ws.readyState <= 1) return // already connecting or open ws=new WebSocket(WS_URL) ws.onopen=()=> { wsReady = true clearTimeout(wsReconnectTimer) console.log('[WS] Connected to cloud backend') updateConnectionStatus(true) } ws.onclose = () => { wsReady = false console.warn('[WS] Disconnected — retrying in 3s') updateConnectionStatus(false) wsReconnectTimer = setTimeout(connectWS, WS_RECONNECT_DELAY) } ws.onerror = (err) => { console.error('[WS] Error:', err) wsReady = false } } function publishToCloud(topic, payload) { if (!wsReady || ws.readyState !== WebSocket.OPEN) return ws.send(JSON.stringify({ topic, payload })) } function updateConnectionStatus(connected) { const indicator = document.querySelector('.status-indicator') if (indicator) { indicator.style.background = connected ? '#4ade80' : '#f87171' indicator.title = connected ? 'Connected to cloud' : 'Disconnected' } } // ───────────────────────────────────────────────────────────────────────────── // SCENE SETUP // ───────────────────────────────────────────────────────────────────────────── let constructionScene let orbitControls let lastTime = Date.now() let lastBroadcastTime = 0 let mqttSim let mqttConsumer const keys = {} window.addEventListener('keydown', (e) => { keys[e.key.toLowerCase()] = true }) window.addEventListener('keyup', (e) => { keys[e.key.toLowerCase()] = false }) function init() { connectWS() constructionScene = new ConstructionScene('canvas-container') constructionScene.init() mqttSim = new MQTTSimulator(constructionScene) mqttConsumer = new MQTTConsumer(constructionScene) mqttSim.start() orbitControls = new THREE.OrbitControls( constructionScene.camera, constructionScene.renderer.domElement ) orbitControls.enableDamping = true orbitControls.dampingFactor = 0.05 orbitControls.minDistance = 10 orbitControls.maxDistance = 300 orbitControls.maxPolarAngle = Math.PI / 2 animate() } // ───────────────────────────────────────────────────────────────────────────── // ANIMATION LOOP // ───────────────────────────────────────────────────────────────────────────── function animate() { requestAnimationFrame(animate) const currentTime = Date.now() const deltaTime = (currentTime - lastTime) / 1000 lastTime = currentTime constructionScene.updateCrane(deltaTime) mqttConsumer.update(deltaTime) constructionScene.updateEquipment(deltaTime) if (constructionScene.progressSystem) { constructionScene.progressSystem.update(deltaTime) } if (constructionScene.weatherSystem) { constructionScene.weatherSystem.update(deltaTime) } // Update random vehicles constructionScene.updateRandomVehicles(deltaTime) // Handle controlled vehicle keyboard input const controlledVehicle = constructionScene.getControlledVehicle() if (controlledVehicle) { const speed = 8 let dx = 0, dz = 0 if (keys['w'] || keys['arrowup']) dz -= 1 if (keys['s'] || keys['arrowdown']) dz += 1 if (keys['a'] || keys['arrowleft']) dx -= 1 if (keys['d'] || keys['arrowright']) dx += 1 if (dx !== 0 || dz !== 0) { const len = Math.sqrt(dx * dx + dz * dz) dx /= len; dz /= len controlledVehicle.position.x += dx * speed * deltaTime controlledVehicle.position.z += dz * speed * deltaTime controlledVehicle.position.x = Math.max(-90, Math.min(90, controlledVehicle.position.x)) controlledVehicle.position.z = Math.max(-90, Math.min(90, controlledVehicle.position.z)) controlledVehicle.position.y = constructionScene.getTerrainHeight( controlledVehicle.position.x, controlledVehicle.position.z ) controlledVehicle.rotation.y = Math.atan2(dx, dz) } } // ── Throttled publish to cloud (~10/s) ────────────────────────────────── if (currentTime - lastBroadcastTime >= 100) { lastBroadcastTime = currentTime publishState(controlledVehicle) } orbitControls.update() constructionScene.render() } // ───────────────────────────────────────────────────────────────────────────── // PUBLISH STATE — send all scene state to cloud backend over WebSocket // ───────────────────────────────────────────────────────────────────────────── function publishState(controlledVehicle) { if (controlledVehicle) { publishToCloud('vehicles/controlled', { position: { x: controlledVehicle.position.x, y: controlledVehicle.position.y, z: controlledVehicle.position.z }, rotation: controlledVehicle.rotation.y }) } publishToCloud('crane/state', { phase: constructionScene.craneState.phase, progress: constructionScene.craneState.progress }) if (constructionScene.weatherSystem) { publishToCloud('site/weather', { timeOfDay: constructionScene.weatherSystem.timeOfDay, rainEnabled: constructionScene.weatherSystem.rainEnabled, fogEnabled: constructionScene.weatherSystem.fogEnabled }) } if (constructionScene.progressSystem) { publishToCloud('site/progress', { buildings: constructionScene.progressSystem.buildings.map(b => ({ currentStage: b.currentStage, stageProgress: b.stageProgress })) }) } if (constructionScene.equipment.length > 0) { publishToCloud('site/equipment', { equipment: constructionScene.equipment.map(e => ({ x: e.position.x, y: e.position.y, z: e.position.z, rotY: e.rotation.y, visible: e.visible })) }) } } window.addEventListener('load', init)