/** * 5-Drone Formation Flight Simulator - Main Application Controller * Three.js 3D Environment, Mouse Raycasting Flight Control, WebSocket Telemetry Sync */ class DroneSimulatorApp { constructor() { this.container = document.getElementById("canvas-container"); this.scene = null; this.camera = null; this.renderer = null; this.controls = null; this.clock = new THREE.Clock(); this.droneModels = []; this.formationBeams = []; this.targetReticle = null; this.targetPillar = null; this.radar = null; // Mouse & Flight Target state this.raycaster = new THREE.Raycaster(); this.mouse = new THREE.Vector2(); this.flightPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), -8.0); this.targetPos = new THREE.Vector3(0, 8.0, 0); this.targetAltitude = 8.0; this.isMouseDown = false; // Camera mode: 'ORBIT', 'CHASE', 'FPV', 'FORMATION' this.cameraMode = 'ORBIT'; // WebSocket Client this.ws = null; this.wsConnected = false; this.lastTelemetry = null; this.initThree(); this.initEnvironment(); this.initDrones(); this.initEventListeners(); this.initWebSocket(); this.initRadar(); this.animate = this.animate.bind(this); requestAnimationFrame(this.animate); } initThree() { // Scene this.scene = new THREE.Scene(); this.scene.background = new THREE.Color(0x060a10); this.scene.fog = new THREE.FogExp2(0x060a10, 0.010); // Camera this.camera = new THREE.PerspectiveCamera(55, window.innerWidth / window.innerHeight, 0.2, 500); this.camera.position.set(0, 26, 38); // WebGL Renderer with Soft Shadows this.renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: "high-performance" }); this.renderer.setSize(window.innerWidth, window.innerHeight); this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); this.renderer.shadowMap.enabled = true; this.renderer.shadowMap.type = THREE.PCFSoftShadowMap; this.renderer.toneMapping = THREE.ACESFilmicToneMapping; this.renderer.toneMappingExposure = 1.1; this.container.appendChild(this.renderer.domElement); // Orbit Controls for Tactical Free Cam this.controls = new THREE.OrbitControls(this.camera, this.renderer.domElement); this.controls.enableDamping = true; this.controls.dampingFactor = 0.05; this.controls.maxPolarAngle = Math.PI / 2 - 0.02; this.controls.minDistance = 4; this.controls.maxDistance = 160; this.controls.target.set(0, 6, 0); // Ambient & Directional Lighting const ambientLight = new THREE.AmbientLight(0x223344, 1.2); this.scene.add(ambientLight); const sunLight = new THREE.DirectionalLight(0xddeeff, 2.0); sunLight.position.set(40, 80, 30); sunLight.castShadow = true; sunLight.shadow.mapSize.width = 2048; sunLight.shadow.mapSize.height = 2048; sunLight.shadow.camera.near = 10; sunLight.shadow.camera.far = 200; sunLight.shadow.camera.left = -70; sunLight.shadow.camera.right = 70; sunLight.shadow.camera.top = 70; sunLight.shadow.camera.bottom = -70; sunLight.shadow.bias = -0.0005; this.scene.add(sunLight); // Cyber Accent Rim Lights const cyanRim = new THREE.DirectionalLight(0x00f0ff, 1.0); cyanRim.position.set(-60, 30, -50); this.scene.add(cyanRim); const pinkRim = new THREE.DirectionalLight(0xff0066, 0.8); pinkRim.position.set(60, 25, 60); this.scene.add(pinkRim); } initEnvironment() { // Ground Grid const gridHelper = new THREE.GridHelper(200, 100, 0x00f0ff, 0x0f2233); gridHelper.position.y = 0.01; this.scene.add(gridHelper); const subGrid = new THREE.GridHelper(200, 20, 0x00f0ff, 0x00f0ff); subGrid.position.y = 0.02; subGrid.material.opacity = 0.3; subGrid.material.transparent = true; this.scene.add(subGrid); // Ground Plane Mesh const groundGeo = new THREE.PlaneGeometry(400, 400); groundGeo.rotateX(-Math.PI / 2); const groundMat = new THREE.MeshStandardMaterial({ color: 0x060a10, roughness: 0.85, metalness: 0.2 }); const groundMesh = new THREE.Mesh(groundGeo, groundMat); groundMesh.receiveShadow = true; this.scene.add(groundMesh); // Concentric Landing Rings const padGroup = new THREE.Group(); const padRing1 = new THREE.Mesh( new THREE.RingGeometry(14, 14.5, 64), new THREE.MeshBasicMaterial({ color: 0x00f0ff, side: THREE.DoubleSide }) ); padRing1.rotateX(-Math.PI / 2); padRing1.position.y = 0.03; padGroup.add(padRing1); const padRing2 = new THREE.Mesh( new THREE.RingGeometry(28, 28.6, 64), new THREE.MeshBasicMaterial({ color: 0x00f0ff, side: THREE.DoubleSide, opacity: 0.4, transparent: true }) ); padRing2.rotateX(-Math.PI / 2); padRing2.position.y = 0.03; padGroup.add(padRing2); this.scene.add(padGroup); // 3D Target Reticle Marker this.targetReticle = new THREE.Group(); const reticleRing = new THREE.Mesh( new THREE.RingGeometry(0.9, 1.15, 32), new THREE.MeshBasicMaterial({ color: 0x00f0ff, side: THREE.DoubleSide, transparent: true, opacity: 0.85 }) ); reticleRing.rotateX(-Math.PI / 2); this.targetReticle.add(reticleRing); const crossGeo = new THREE.BufferGeometry().setFromPoints([ new THREE.Vector3(-1.6, 0, 0), new THREE.Vector3(1.6, 0, 0), new THREE.Vector3(0, 0, -1.6), new THREE.Vector3(0, 0, 1.6) ]); const crossMat = new THREE.LineBasicMaterial({ color: 0x00f0ff, opacity: 0.9, transparent: true }); this.targetReticle.add(new THREE.LineSegments(crossGeo, crossMat)); // Altitude Drop Pillar const pillarGeo = new THREE.BufferGeometry().setFromPoints([ new THREE.Vector3(0, 0, 0), new THREE.Vector3(0, 8, 0) ]); this.targetPillar = new THREE.Line(pillarGeo, new THREE.LineDashedMaterial({ color: 0x00f0ff, dashSize: 0.5, gapSize: 0.3, transparent: true, opacity: 0.6 })); this.targetPillar.computeLineDistances(); this.scene.add(this.targetPillar); this.scene.add(this.targetReticle); } initDrones() { // 5 Drone Configuration: Leader + 4 Wingmen const droneConfigs = [ { id: "alpha_1", name: "VIPER-01 (Leader)", color: 0x00f0ff, isLeader: true }, { id: "alpha_2", name: "GHOST-02 (Wingman 1)", color: 0xff0055, isLeader: false }, { id: "alpha_3", name: "PHANTOM-03 (Wingman 2)", color: 0x00ff88, isLeader: false }, { id: "alpha_4", name: "SPECTER-04 (Wingman 3)", color: 0xffb700, isLeader: false }, { id: "alpha_5", name: "SHADOW-05 (Wingman 4)", color: 0xb026ff, isLeader: false }, ]; this.droneModels = droneConfigs.map(cfg => { return new DroneModel(this.scene, { id: cfg.id, name: cfg.name, primaryColor: cfg.color, isLeader: cfg.isLeader }); }); // 4 Formation Laser Beams connecting Leader to each Wingman this.formationBeams = []; for (let i = 1; i < 5; i++) { const beamGeo = new THREE.BufferGeometry().setFromPoints([ new THREE.Vector3(0, 8, 0), new THREE.Vector3(i * 3, 8, -i * 3) ]); const beamMat = new THREE.LineBasicMaterial({ color: droneConfigs[i].color, transparent: true, opacity: 0.55, linewidth: 1.5 }); const beam = new THREE.Line(beamGeo, beamMat); this.scene.add(beam); this.formationBeams.push(beam); } } initRadar() { this.radar = new TacticalRadar("tactical-radar"); } initWebSocket() { const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; const wsUrl = `${protocol}//${window.location.host}/ws`; const connect = () => { this.ws = new WebSocket(wsUrl); this.ws.onopen = () => { this.wsConnected = true; const statusDot = document.getElementById("connection-status-dot"); const statusText = document.getElementById("connection-status-text"); if (statusDot) statusDot.className = "status-dot"; if (statusText) statusText.innerText = "ONLINE (5-DRONES 60Hz)"; }; this.ws.onmessage = (event) => { try { const data = JSON.parse(event.data); this.lastTelemetry = data; this.updateUI(data); } catch (e) { console.error("Telemetry error", e); } }; this.ws.onclose = () => { this.wsConnected = false; const statusDot = document.getElementById("connection-status-dot"); const statusText = document.getElementById("connection-status-text"); if (statusDot) statusDot.className = "status-dot warning"; if (statusText) statusText.innerText = "OFFLINE (RETRYING...)"; setTimeout(connect, 2000); }; }; connect(); } sendWsCommand(payload) { if (this.ws && this.wsConnected && this.ws.readyState === WebSocket.OPEN) { this.ws.send(JSON.stringify(payload)); } } initEventListeners() { window.addEventListener("resize", () => { this.camera.aspect = window.innerWidth / window.innerHeight; this.camera.updateProjectionMatrix(); this.renderer.setSize(window.innerWidth, window.innerHeight); }); window.addEventListener("pointerdown", (e) => { if (e.target.closest(".side-panel, .top-nav, .bottom-bar")) return; this.isMouseDown = true; this.updateMouseTarget(e); }); window.addEventListener("pointermove", (e) => { if (e.target.closest(".side-panel, .top-nav, .bottom-bar")) return; if (this.isMouseDown || e.buttons === 1) { this.updateMouseTarget(e); } }); window.addEventListener("pointerup", () => { this.isMouseDown = false; }); // Altitude Adjustment with Mouse Wheel window.addEventListener("wheel", (e) => { if (e.target.closest(".side-panel")) return; if (e.ctrlKey || e.altKey) return; const delta = e.deltaY > 0 ? -0.8 : 0.8; this.targetAltitude = Math.max(1.5, Math.min(35.0, this.targetAltitude + delta)); this.flightPlane.constant = -this.targetAltitude; const altSlider = document.getElementById("altitude-slider"); const altVal = document.getElementById("alt-value"); if (altSlider) altSlider.value = this.targetAltitude; if (altVal) altVal.innerText = `${this.targetAltitude.toFixed(1)}m`; this.sendWsCommand({ type: "SET_TARGET", x: this.targetPos.x, y: this.targetAltitude, z: this.targetPos.z }); }, { passive: true }); // Keyboard Controls window.addEventListener("keydown", (e) => { if (e.key === "w" || e.key === "W" || e.key === "ArrowUp") { this.targetAltitude = Math.min(35.0, this.targetAltitude + 1.0); } else if (e.key === "s" || e.key === "S" || e.key === "ArrowDown") { this.targetAltitude = Math.max(1.5, this.targetAltitude - 1.0); } else if (e.key === "1") { this.setCameraMode("ORBIT"); } else if (e.key === "2") { this.setCameraMode("CHASE"); } else if (e.key === "3") { this.setCameraMode("FPV"); } else if (e.key === "4") { this.setCameraMode("FORMATION"); } else if (e.key === "m" || e.key === "M") { this.toggleAudio(); } else if (e.key === " ") { this.targetPos.set(0, this.targetAltitude, 0); } this.flightPlane.constant = -this.targetAltitude; this.sendWsCommand({ type: "SET_TARGET", x: this.targetPos.x, y: this.targetAltitude, z: this.targetPos.z }); }); // Formation Mode Buttons document.querySelectorAll(".btn-formation").forEach(btn => { btn.addEventListener("click", () => { document.querySelectorAll(".btn-formation").forEach(b => b.classList.remove("active")); btn.classList.add("active"); const mode = btn.dataset.mode; this.sendWsCommand({ type: "SET_FORMATION", mode: mode }); if (window.droneAudio) window.droneAudio.playBeep(920, 0.06); }); }); // Sliders const distSlider = document.getElementById("formation-dist-slider"); if (distSlider) { distSlider.addEventListener("input", (e) => { const dist = parseFloat(e.target.value); document.getElementById("dist-value").innerText = `${dist.toFixed(1)}m`; this.sendWsCommand({ type: "SET_FORMATION", distance: dist }); }); } const heightSlider = document.getElementById("formation-height-slider"); if (heightSlider) { heightSlider.addEventListener("input", (e) => { const hOff = parseFloat(e.target.value); document.getElementById("height-value").innerText = `${hOff > 0 ? '+' : ''}${hOff.toFixed(1)}m`; this.sendWsCommand({ type: "SET_FORMATION", height_offset: hOff }); }); } const speedSlider = document.getElementById("speed-slider"); if (speedSlider) { speedSlider.addEventListener("input", (e) => { const spd = parseFloat(e.target.value); document.getElementById("speed-value").innerText = `${spd.toFixed(1)}x`; this.sendWsCommand({ type: "SET_SPEED", speed: spd }); }); } const altSlider = document.getElementById("altitude-slider"); if (altSlider) { altSlider.addEventListener("input", (e) => { this.targetAltitude = parseFloat(e.target.value); document.getElementById("alt-value").innerText = `${this.targetAltitude.toFixed(1)}m`; this.flightPlane.constant = -this.targetAltitude; this.sendWsCommand({ type: "SET_TARGET", x: this.targetPos.x, y: this.targetAltitude, z: this.targetPos.z }); }); } const autoToggle = document.getElementById("auto-flight-toggle"); const autoSelect = document.getElementById("auto-pattern-select"); if (autoToggle) { autoToggle.addEventListener("change", (e) => { const enabled = e.target.checked; const pattern = autoSelect ? autoSelect.value : "FIGURE_EIGHT"; this.sendWsCommand({ type: "SET_AUTONOMOUS", enabled: enabled, pattern: pattern }); if (window.droneAudio) window.droneAudio.playBeep(enabled ? 1200 : 600, 0.1); }); } if (autoSelect) { autoSelect.addEventListener("change", (e) => { const pattern = e.target.value; const enabled = autoToggle ? autoToggle.checked : false; this.sendWsCommand({ type: "SET_AUTONOMOUS", enabled: enabled, pattern: pattern }); }); } document.querySelectorAll(".btn-cam").forEach(btn => { btn.addEventListener("click", () => { this.setCameraMode(btn.dataset.cam); }); }); const resetBtn = document.getElementById("btn-reset"); if (resetBtn) { resetBtn.addEventListener("click", () => { this.targetPos.set(0, 8.0, 0); this.targetAltitude = 8.0; this.flightPlane.constant = -8.0; this.sendWsCommand({ type: "RESET_POSITION" }); if (window.droneAudio) window.droneAudio.playBeep(440, 0.15); }); } const audioBtn = document.getElementById("btn-audio-toggle"); if (audioBtn) { audioBtn.addEventListener("click", () => { this.toggleAudio(); }); } } toggleAudio() { if (!window.droneAudio) return; const active = window.droneAudio.toggle(); const btn = document.getElementById("btn-audio-toggle"); if (btn) { btn.innerText = active ? "🔊 SFX ON" : "🔇 SFX OFF"; btn.style.borderColor = active ? "var(--color-cyan)" : "rgba(255,255,255,0.2)"; } } setCameraMode(mode) { this.cameraMode = mode; document.querySelectorAll(".btn-cam").forEach(b => { b.classList.toggle("active", b.dataset.cam === mode); }); if (mode === 'ORBIT') { this.controls.enabled = true; } else { this.controls.enabled = false; } if (window.droneAudio) window.droneAudio.playBeep(1050, 0.05); } updateMouseTarget(e) { this.mouse.x = (e.clientX / window.innerWidth) * 2 - 1; this.mouse.y = -(e.clientY / window.innerHeight) * 2 + 1; this.raycaster.setFromCamera(this.mouse, this.camera); const intersectPoint = new THREE.Vector3(); if (this.raycaster.ray.intersectPlane(this.flightPlane, intersectPoint)) { this.targetPos.x = intersectPoint.x; this.targetPos.y = this.targetAltitude; this.targetPos.z = intersectPoint.z; this.sendWsCommand({ type: "SET_TARGET", x: this.targetPos.x, y: this.targetAltitude, z: this.targetPos.z }); } } updateUI(telemetry) { if (!telemetry || !telemetry.drones) return; const drones = telemetry.drones; const formation = telemetry.formation; // Render each drone in the telemetry list dynamically drones.forEach((d, idx) => { const posEl = document.getElementById(`tele-d${idx}-pos`); const spdEl = document.getElementById(`tele-d${idx}-spd`); const altEl = document.getElementById(`tele-d${idx}-alt`); const batEl = document.getElementById(`tele-d${idx}-bat`); const barEl = document.getElementById(`bar-d${idx}-bat`); if (posEl) posEl.innerText = `${d.position.x}, ${d.position.y}, ${d.position.z}`; if (spdEl) spdEl.innerText = `${d.speed} km/h`; if (altEl) altEl.innerText = `${d.position.y.toFixed(1)}m`; if (batEl) batEl.innerText = `${d.battery}%`; if (barEl) barEl.style.width = `${d.battery}%`; }); // Formation Card const gaugeDist = document.getElementById("gauge-dist-val"); if (gaugeDist) gaugeDist.innerText = `${formation.distance.toFixed(1)}m`; const statMsg = document.getElementById("formation-status-msg"); if (statMsg) statMsg.innerText = `5-DRONE SWARM: ${formation.mode}`; // Audio update (leader + wingman 1) if (window.droneAudio && drones.length >= 2) { window.droneAudio.updateMotors(drones[0].rotors[0], drones[1].rotors[0], drones[0].speed, drones[1].speed); } } updateCamera(dt) { if (!this.lastTelemetry || !this.lastTelemetry.drones) return; const leader = this.lastTelemetry.drones[0]; const leaderPos = leader.position; const leaderYaw = THREE.MathUtils.degToRad(leader.rotation.yaw || 0); const lVec = new THREE.Vector3(leaderPos.x, leaderPos.y, leaderPos.z); if (this.cameraMode === 'CHASE') { const offset = new THREE.Vector3(-Math.sin(leaderYaw) * 18, 8.0, -Math.cos(leaderYaw) * 18); const targetCamPos = lVec.clone().add(offset); this.camera.position.lerp(targetCamPos, dt * 4.0); this.camera.lookAt(lVec.clone().add(new THREE.Vector3(0, 1.0, 0))); } else if (this.cameraMode === 'FPV') { this.camera.position.copy(lVec).add(new THREE.Vector3(0, -0.2, 0.8)); const forward = new THREE.Vector3(Math.sin(leaderYaw), 0, Math.cos(leaderYaw)); this.camera.lookAt(this.camera.position.clone().add(forward.multiplyScalar(20))); } else if (this.cameraMode === 'FORMATION') { // High angle side profile showing all 5 drones const sideOffset = new THREE.Vector3(26, 12, 0); this.camera.position.lerp(lVec.clone().add(sideOffset), dt * 3.0); this.camera.lookAt(lVec); } else { this.controls.update(); } } animate() { requestAnimationFrame(this.animate); const dt = Math.min(0.05, this.clock.getDelta()); // Update 3D Target Reticle & Pillar if (this.targetReticle) { this.targetReticle.position.set(this.targetPos.x, 0.04, this.targetPos.z); this.targetReticle.rotation.y += 0.02; const pPos = new Float32Array([ this.targetPos.x, 0, this.targetPos.z, this.targetPos.x, this.targetAltitude, this.targetPos.z ]); this.targetPillar.geometry.setAttribute('position', new THREE.BufferAttribute(pPos, 3)); this.targetPillar.computeLineDistances(); } // Render 5 Drones from Telemetry Stream if (this.lastTelemetry && this.lastTelemetry.drones) { const drones = this.lastTelemetry.drones; drones.forEach((d, idx) => { if (this.droneModels[idx]) { this.droneModels[idx].update(d, dt); } }); // Update 4 Formation Laser Beams from Leader to Wingmen const lp = drones[0].position; for (let i = 1; i < drones.length; i++) { if (this.formationBeams[i - 1]) { const fp = drones[i].position; const beamPoints = new Float32Array([ lp.x, lp.y, lp.z, fp.x, fp.y, fp.z ]); this.formationBeams[i - 1].geometry.setAttribute('position', new THREE.BufferAttribute(beamPoints, 3)); } } // Update 2D Tactical Radar HUD if (this.radar) { this.radar.render(drones); } } this.updateCamera(dt); this.renderer.render(this.scene, this.camera); } } window.addEventListener("DOMContentLoaded", () => { window.simApp = new DroneSimulatorApp(); });