최초 커밋
This commit is contained in:
1 parent
a28d331e3e
commit
618ba02cb9
124 files changed
+49297
-2
No files matched your search
@@ -0,0 +1,48 @@
|
||||
# 🚀 드론 5대 군집 편대 비행 시뮬레이터 (5-Drone Swarm Formation Simulator)
|
||||
|
||||
Three.js 3D 그래픽 엔진과 Python 백엔드(Starlette + Uvicorn + WebSockets)를 연동하여 **드론 5대(리더 1대 + 윙맨 4대)**의 군집 편대 비행을 마우스로 실시간 조종하고 관제할 수 있는 고성능 시뮬레이터입니다.
|
||||
|
||||
---
|
||||
|
||||
## 🌟 드론 5대 군집 구성
|
||||
|
||||
| 콜사인 | 명칭 | 색상 | 역할 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **ALPHA-01** | `VIPER-01` | **Cyan (#00f0ff)** | 편대장 / 리더 드론 (마우스 목표 추종) |
|
||||
| **ALPHA-02** | `GHOST-02` | **Pink (#ff0055)** | 윙맨 1 (우측 내측 Starboard Inner) |
|
||||
| **ALPHA-03** | `PHANTOM-03` | **Green (#00ff88)** | 윙맨 2 (좌측 내측 Port Inner) |
|
||||
| **ALPHA-04** | `SPECTER-04` | **Amber (#ffb700)** | 윙맨 3 (우측 외측 Starboard Outer) |
|
||||
| **ALPHA-05** | `SHADOW-05` | **Purple (#b026ff)** | 윙맨 4 (좌측 외측 Port Outer) |
|
||||
|
||||
---
|
||||
|
||||
## 📐 5대 군집 편대 모드 (Swarm Formation Modes)
|
||||
|
||||
1. **V 편대 (V-Formation)**: 리더를 정점으로 4대의 윙맨이 좌우 대칭 V자 대형 유지
|
||||
2. **다이아몬드 (Diamond / Arrowhead)**: 리더(선두), 좌/우익, 중앙 후방, 후미 고공 경계 대형
|
||||
3. **횡대 일렬 (Line Abreast / Side-by-Side)**: 5대의 드론이 좌우로 넓게 일렬 횡대 대형 유지
|
||||
4. **종대 직렬 (Column / Tandem / Trail)**: 5대의 드론이 리더 뒤로 일렬 종대 추종
|
||||
5. **군집 선회 (Swarm Orbit)**: 4대의 윙맨이 리더 주위를 90도 간격으로 3D 나선 회전 선회
|
||||
6. **사선 편대 (Echelon Right)**: 우측 45도 대각선 사선 대형
|
||||
|
||||
---
|
||||
|
||||
## 🎮 실행 및 조종 방법
|
||||
|
||||
### 실행
|
||||
```powershell
|
||||
# uploads/DRON 디렉토리에서 실행
|
||||
.\run.bat
|
||||
```
|
||||
또는
|
||||
```powershell
|
||||
..\..\.venv\Scripts\python.exe app.py
|
||||
```
|
||||
브라우저에서 **`http://127.0.0.1:8000`** 접속
|
||||
|
||||
### 조종 키
|
||||
- **마우스 클릭 / 드래그**: 3D 공간 상에서 리더 드론의 목표 비행 위치 지정 (나머지 4대의 윙맨이 편대를 유지하며 자동 추종)
|
||||
- **마우스 휠 / `W`, `S` 키**: 군집 비행 고도(Altitude) 상승 / 하강
|
||||
- **숫자키 `1` ~ `4`**: 카메라 모드 전환 (1: 군집 궤도뷰, 2: 리더 추적뷰, 3: FPV 조종석, 4: 편대 프로필)
|
||||
- **`M` 키**: 드론 엔진 사운드 On/Off
|
||||
- **`Space` 키**: 착륙 패드 중앙으로 위치 초기화
|
||||
@@ -0,0 +1,466 @@
|
||||
"""
|
||||
Drone Formation Flight Simulation Server
|
||||
Real-time 6-DOF physics and 5-Drone Swarm Formation calculation backend with WebSockets & Starlette.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import math
|
||||
import time
|
||||
import os
|
||||
import contextlib
|
||||
import sys
|
||||
from typing import Dict, Any, List
|
||||
import uvicorn
|
||||
from starlette.applications import Starlette
|
||||
from starlette.routing import Route, WebSocketRoute, Mount
|
||||
from starlette.staticfiles import StaticFiles
|
||||
from starlette.responses import FileResponse, JSONResponse
|
||||
from starlette.websockets import WebSocket, WebSocketDisconnect
|
||||
|
||||
# Directory setup
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
STATIC_DIR = os.path.join(BASE_DIR, "static")
|
||||
|
||||
os.makedirs(STATIC_DIR, exist_ok=True)
|
||||
os.makedirs(os.path.join(STATIC_DIR, "css"), exist_ok=True)
|
||||
os.makedirs(os.path.join(STATIC_DIR, "js"), exist_ok=True)
|
||||
|
||||
|
||||
class Vector3:
|
||||
def __init__(self, x=0.0, y=0.0, z=0.0):
|
||||
self.x = float(x)
|
||||
self.y = float(y)
|
||||
self.z = float(z)
|
||||
|
||||
def to_dict(self):
|
||||
return {"x": round(self.x, 3), "y": round(self.y, 3), "z": round(self.z, 3)}
|
||||
|
||||
def dist_to(self, other: 'Vector3') -> float:
|
||||
return math.sqrt((self.x - other.x)**2 + (self.y - other.y)**2 + (self.z - other.z)**2)
|
||||
|
||||
|
||||
class DroneState:
|
||||
def __init__(self, drone_id: str, name: str, callsign: str, initial_pos: Vector3, color: str, is_leader: bool = False):
|
||||
self.id = drone_id
|
||||
self.name = name
|
||||
self.callsign = callsign
|
||||
self.pos = initial_pos
|
||||
self.target_pos = Vector3(initial_pos.x, initial_pos.y, initial_pos.z)
|
||||
self.vel = Vector3(0.0, 0.0, 0.0)
|
||||
self.pitch = 0.0 # radians
|
||||
self.roll = 0.0 # radians
|
||||
self.yaw = 0.0 # radians
|
||||
self.rotor_rpm = [5200.0, 5200.0, 5200.0, 5200.0]
|
||||
self.battery = 98.5 # percentage
|
||||
self.armed = True
|
||||
self.color = color
|
||||
self.is_leader = is_leader
|
||||
self.status = "AIRBORNE - FORMATION OK"
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"callsign": self.callsign,
|
||||
"position": self.pos.to_dict(),
|
||||
"target": self.target_pos.to_dict(),
|
||||
"velocity": self.vel.to_dict(),
|
||||
"speed": round(math.sqrt(self.vel.x**2 + self.vel.y**2 + self.vel.z**2) * 3.6, 1), # km/h
|
||||
"rotation": {
|
||||
"pitch": round(math.degrees(self.pitch), 1),
|
||||
"roll": round(math.degrees(self.roll), 1),
|
||||
"yaw": round(math.degrees(self.yaw), 1)
|
||||
},
|
||||
"rotors": [round(r, 0) for r in self.rotor_rpm],
|
||||
"battery": round(self.battery, 1),
|
||||
"armed": self.armed,
|
||||
"color": self.color,
|
||||
"is_leader": self.is_leader,
|
||||
"status": self.status
|
||||
}
|
||||
|
||||
|
||||
class FormationEngine:
|
||||
"""Manages 5-Drone swarm flight physics and dynamic formation geometry"""
|
||||
def __init__(self):
|
||||
# 5 Drones Configuration
|
||||
self.drones: List[DroneState] = [
|
||||
DroneState("alpha_1", "Leader Drone", "VIPER-01", Vector3(0.0, 8.0, 0.0), "#00f0ff", is_leader=True),
|
||||
DroneState("alpha_2", "Wingman 1 (R1)", "GHOST-02", Vector3(5.0, 8.0, -5.0), "#ff0055"),
|
||||
DroneState("alpha_3", "Wingman 2 (L1)", "PHANTOM-03", Vector3(-5.0, 8.0, -5.0), "#00ff88"),
|
||||
DroneState("alpha_4", "Wingman 3 (R2)", "SPECTER-04", Vector3(10.0, 8.0, -10.0),"#ffb700"),
|
||||
DroneState("alpha_5", "Wingman 4 (L2)", "SHADOW-05", Vector3(-10.0, 8.0,-10.0),"#b026ff")
|
||||
]
|
||||
|
||||
self.leader = self.drones[0]
|
||||
self.formation_mode = "V_FORMATION" # V_FORMATION, SIDE_BY_SIDE, TANDEM, DIAMOND, ORBIT, VERTICAL, ECHELON
|
||||
self.formation_distance = 6.0
|
||||
self.formation_height_offset = 0.0
|
||||
self.speed_multiplier = 1.0
|
||||
self.autonomous_mode = False
|
||||
self.auto_pattern = "FIGURE_EIGHT" # FIGURE_EIGHT, CIRCLE, WAYPOINT_PATROL
|
||||
|
||||
self.orbit_angle = 0.0
|
||||
self.sim_time = 0.0
|
||||
self.clients: List[WebSocket] = []
|
||||
|
||||
def set_target(self, x: float, y: float, z: float):
|
||||
"""Set manual flight target for the leader drone"""
|
||||
self.leader.target_pos.x = max(-80.0, min(80.0, float(x)))
|
||||
self.leader.target_pos.y = max(1.5, min(40.0, float(y)))
|
||||
self.leader.target_pos.z = max(-80.0, min(80.0, float(z)))
|
||||
|
||||
def calculate_follower_targets(self) -> List[Vector3]:
|
||||
"""Calculates target 3D coordinates for all 4 wingmen based on formation geometry and leader state"""
|
||||
lx, ly, lz = self.leader.pos.x, self.leader.pos.y, self.leader.pos.z
|
||||
yaw = self.leader.yaw
|
||||
dist = self.formation_distance
|
||||
h_off = self.formation_height_offset
|
||||
|
||||
# Unit directional vectors based on leader yaw
|
||||
# Forward vector
|
||||
fx = math.sin(yaw)
|
||||
fz = math.cos(yaw)
|
||||
# Right vector
|
||||
rx = math.cos(yaw)
|
||||
rz = -math.sin(yaw)
|
||||
|
||||
targets = []
|
||||
|
||||
if self.formation_mode == "V_FORMATION":
|
||||
# Classic 5-Drone V-Shape
|
||||
offsets = [
|
||||
# (Right factor, Forward factor, Height factor)
|
||||
(1.0, -1.0, 0.0), # Drone 2: Starboard Inner
|
||||
(-1.0, -1.0, 0.0), # Drone 3: Port Inner
|
||||
(2.0, -2.0, 0.5), # Drone 4: Starboard Outer
|
||||
(-2.0, -2.0, 0.5), # Drone 5: Port Outer
|
||||
]
|
||||
for r_fac, f_fac, h_fac in offsets:
|
||||
tx = lx + (rx * r_fac + fx * f_fac) * dist
|
||||
tz = lz + (rz * r_fac + fz * f_fac) * dist
|
||||
ty = ly + h_off + (h_fac * h_off)
|
||||
targets.append(Vector3(tx, ty, tz))
|
||||
|
||||
elif self.formation_mode == "SIDE_BY_SIDE":
|
||||
# 5-Drone Wide Line Abreast: L2, L1, Leader, R1, R2
|
||||
offsets = [
|
||||
(1.0, 0.0), # D2: Right 1
|
||||
(-1.0, 0.0), # D3: Left 1
|
||||
(2.0, 0.0), # D4: Right 2
|
||||
(-2.0, 0.0), # D5: Left 2
|
||||
]
|
||||
for r_fac, f_fac in offsets:
|
||||
tx = lx + (rx * r_fac) * dist
|
||||
tz = lz + (rz * r_fac) * dist
|
||||
targets.append(Vector3(tx, ly + h_off, tz))
|
||||
|
||||
elif self.formation_mode == "TANDEM":
|
||||
# 5-Drone Trail / Single Column In-Line behind Leader
|
||||
offsets = [-1.0, -2.0, -3.0, -4.0]
|
||||
for i, f_fac in enumerate(offsets):
|
||||
tx = lx + (fx * f_fac) * dist
|
||||
tz = lz + (fz * f_fac) * dist
|
||||
ty = ly + (h_off * (i + 1) * 0.3)
|
||||
targets.append(Vector3(tx, ty, tz))
|
||||
|
||||
elif self.formation_mode == "DIAMOND":
|
||||
# 5-Drone Diamond / Arrowhead formation
|
||||
offsets = [
|
||||
(1.2, -0.8, 0.0), # D2: Right Wing
|
||||
(-1.2, -0.8, 0.0), # D3: Left Wing
|
||||
(0.0, -1.6, 0.0), # D4: Center Rear
|
||||
(0.0, -2.6, 0.5), # D5: Tail High-Guard
|
||||
]
|
||||
for r_fac, f_fac, h_fac in offsets:
|
||||
tx = lx + (rx * r_fac + fx * f_fac) * dist
|
||||
tz = lz + (rz * r_fac + fz * f_fac) * dist
|
||||
ty = ly + h_off + (h_fac * 1.5)
|
||||
targets.append(Vector3(tx, ty, tz))
|
||||
|
||||
elif self.formation_mode == "ORBIT":
|
||||
# 4 Wingmen dynamically orbiting around Leader at 90-degree spacing
|
||||
for i in range(4):
|
||||
angle = self.orbit_angle + (i * math.pi * 0.5)
|
||||
ox = math.cos(angle) * dist
|
||||
oz = math.sin(angle) * dist
|
||||
oy = math.sin(angle * 2.0) * 1.5
|
||||
targets.append(Vector3(lx + ox, ly + h_off + oy, lz + oz))
|
||||
|
||||
elif self.formation_mode == "ECHELON_RIGHT":
|
||||
# Echelon Right (45 deg diagonal trail to the right)
|
||||
for i in range(1, 5):
|
||||
tx = lx + (rx * i - fx * i) * (dist * 0.75)
|
||||
tz = lz + (rz * i - fz * i) * (dist * 0.75)
|
||||
targets.append(Vector3(tx, ly + h_off + (i * 0.4), tz))
|
||||
|
||||
elif self.formation_mode == "VERTICAL":
|
||||
# 3D Stack / Ladder
|
||||
offsets = [
|
||||
(0.0, 0.0, 1.0), # D2: Top +1
|
||||
(0.0, 0.0, -1.0), # D3: Bottom -1
|
||||
(0.5, -0.5, 2.0), # D4: High Rear
|
||||
(-0.5, -0.5, -2.0) # D5: Low Rear
|
||||
]
|
||||
for r_fac, f_fac, h_fac in offsets:
|
||||
tx = lx + (rx * r_fac + fx * f_fac) * (dist * 0.6)
|
||||
tz = lz + (rz * r_fac + fz * f_fac) * (dist * 0.6)
|
||||
ty = ly + (h_fac * dist * 0.6) + h_off
|
||||
targets.append(Vector3(tx, ty, tz))
|
||||
|
||||
else:
|
||||
# Fallback
|
||||
for i in range(1, 5):
|
||||
targets.append(Vector3(lx + i * 4.0, ly, lz))
|
||||
|
||||
return targets
|
||||
|
||||
def update_physics(self, dt: float):
|
||||
"""Simulate realistic 6-DOF drone dynamics for all 5 drones"""
|
||||
self.sim_time += dt
|
||||
self.orbit_angle += dt * 1.2
|
||||
|
||||
# Autonomous flight patterns if enabled
|
||||
if self.autonomous_mode:
|
||||
t = self.sim_time * 0.35 * self.speed_multiplier
|
||||
if self.auto_pattern == "FIGURE_EIGHT":
|
||||
self.leader.target_pos.x = math.sin(t) * 35.0
|
||||
self.leader.target_pos.z = math.sin(t * 2.0) * 20.0
|
||||
self.leader.target_pos.y = 8.0 + math.sin(t * 3.0) * 3.0
|
||||
elif self.auto_pattern == "CIRCLE":
|
||||
self.leader.target_pos.x = math.cos(t) * 30.0
|
||||
self.leader.target_pos.z = math.sin(t) * 30.0
|
||||
self.leader.target_pos.y = 10.0 + math.cos(t * 2.0) * 2.5
|
||||
elif self.auto_pattern == "WAYPOINT_PATROL":
|
||||
wps = [Vector3(-25, 8, -25), Vector3(25, 12, -25), Vector3(25, 8, 25), Vector3(-25, 14, 25)]
|
||||
idx = int((t * 0.5) % 4)
|
||||
next_idx = (idx + 1) % 4
|
||||
blend = (t * 0.5) % 1.0
|
||||
p1 = wps[idx]
|
||||
p2 = wps[next_idx]
|
||||
self.leader.target_pos.x = p1.x + (p2.x - p1.x) * blend
|
||||
self.leader.target_pos.y = p1.y + (p2.y - p1.y) * blend
|
||||
self.leader.target_pos.z = p1.z + (p2.z - p1.z) * blend
|
||||
|
||||
# 1. Simulate Leader Drone (Moves toward target)
|
||||
self._simulate_drone(self.leader, self.leader.target_pos, dt, responsiveness=3.2 * self.speed_multiplier)
|
||||
|
||||
# 2. Simulate 4 Follower Drones
|
||||
follower_targets = self.calculate_follower_targets()
|
||||
for idx, target in enumerate(follower_targets):
|
||||
drone = self.drones[idx + 1]
|
||||
drone.target_pos = target
|
||||
self._simulate_drone(drone, target, dt, responsiveness=3.6 * self.speed_multiplier)
|
||||
|
||||
# Sync status
|
||||
err = drone.pos.dist_to(target)
|
||||
if err < 0.6:
|
||||
drone.status = "FORMATION LOCKED"
|
||||
elif err < 2.5:
|
||||
drone.status = "TRACKING"
|
||||
else:
|
||||
drone.status = "REJOINING"
|
||||
|
||||
# Battery consumption
|
||||
drone.battery = max(5.0, drone.battery - dt * 0.012)
|
||||
|
||||
self.leader.battery = max(5.0, self.leader.battery - dt * 0.012)
|
||||
|
||||
def _simulate_drone(self, drone: DroneState, target: Vector3, dt: float, responsiveness: float):
|
||||
"""Critically damped spring-damper controller for drone navigation + attitude banking"""
|
||||
ex = target.x - drone.pos.x
|
||||
ey = target.y - drone.pos.y
|
||||
ez = target.z - drone.pos.z
|
||||
|
||||
k_p = responsiveness * 3.5
|
||||
k_d = 2.4 * math.sqrt(k_p)
|
||||
|
||||
acc_x = (ex * k_p) - (drone.vel.x * k_d)
|
||||
acc_y = (ey * (k_p * 1.2)) - (drone.vel.y * k_d)
|
||||
acc_z = (ez * k_p) - (drone.vel.z * k_d)
|
||||
|
||||
max_acc = 22.0
|
||||
acc_mag = math.sqrt(acc_x**2 + acc_y**2 + acc_z**2)
|
||||
if acc_mag > max_acc:
|
||||
acc_x = (acc_x / acc_mag) * max_acc
|
||||
acc_y = (acc_y / acc_mag) * max_acc
|
||||
acc_z = (acc_z / acc_mag) * max_acc
|
||||
|
||||
drone.vel.x += acc_x * dt
|
||||
drone.vel.y += acc_y * dt
|
||||
drone.vel.z += acc_z * dt
|
||||
|
||||
drone.pos.x += drone.vel.x * dt
|
||||
drone.pos.y += drone.vel.y * dt
|
||||
drone.pos.z += drone.vel.z * dt
|
||||
|
||||
# Yaw alignment towards velocity
|
||||
horizontal_speed = math.sqrt(drone.vel.x**2 + drone.vel.z**2)
|
||||
if horizontal_speed > 0.3:
|
||||
desired_yaw = math.atan2(drone.vel.x, drone.vel.z)
|
||||
diff = (desired_yaw - drone.yaw + math.pi) % (2 * math.pi) - math.pi
|
||||
drone.yaw += diff * min(1.0, dt * 4.0)
|
||||
|
||||
# Body-frame pitch & roll
|
||||
cos_y = math.cos(drone.yaw)
|
||||
sin_y = math.sin(drone.yaw)
|
||||
forward_acc = acc_z * cos_y + acc_x * sin_y
|
||||
lateral_acc = acc_x * cos_y - acc_z * sin_y
|
||||
|
||||
desired_pitch = -math.atan2(forward_acc, 9.81) * 0.7
|
||||
desired_roll = math.atan2(lateral_acc, 9.81) * 0.7
|
||||
|
||||
max_tilt = math.radians(35.0)
|
||||
desired_pitch = max(-max_tilt, min(max_tilt, desired_pitch))
|
||||
desired_roll = max(-max_tilt, min(max_tilt, desired_roll))
|
||||
|
||||
drone.pitch += (desired_pitch - drone.pitch) * min(1.0, dt * 7.0)
|
||||
drone.roll += (desired_roll - drone.roll) * min(1.0, dt * 7.0)
|
||||
|
||||
base_rpm = 5000.0 + (acc_y * 120.0)
|
||||
tilt_factor = math.sqrt(drone.pitch**2 + drone.roll**2) * 1500.0
|
||||
drone.rotor_rpm[0] = max(1000.0, base_rpm + tilt_factor + drone.roll * 800.0)
|
||||
drone.rotor_rpm[1] = max(1000.0, base_rpm + tilt_factor - drone.roll * 800.0)
|
||||
drone.rotor_rpm[2] = max(1000.0, base_rpm + tilt_factor + drone.pitch * 800.0)
|
||||
drone.rotor_rpm[3] = max(1000.0, base_rpm + tilt_factor - drone.pitch * 800.0)
|
||||
|
||||
def get_state_payload(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"timestamp": time.time(),
|
||||
"count": len(self.drones),
|
||||
"leader": self.leader.to_dict(),
|
||||
"drones": [d.to_dict() for d in self.drones],
|
||||
"formation": {
|
||||
"mode": self.formation_mode,
|
||||
"distance": round(self.formation_distance, 2),
|
||||
"target_distance": self.formation_distance,
|
||||
"height_offset": self.formation_height_offset,
|
||||
"autonomous": self.autonomous_mode,
|
||||
"auto_pattern": self.auto_pattern,
|
||||
"speed_multiplier": round(self.speed_multiplier, 2)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
engine = FormationEngine()
|
||||
|
||||
|
||||
# Starlette App Endpoints
|
||||
async def homepage(request):
|
||||
index_file = os.path.join(STATIC_DIR, "index.html")
|
||||
return FileResponse(index_file)
|
||||
|
||||
|
||||
async def api_status(request):
|
||||
return JSONResponse(engine.get_state_payload())
|
||||
|
||||
|
||||
async def websocket_endpoint(websocket: WebSocket):
|
||||
await websocket.accept()
|
||||
engine.clients.append(websocket)
|
||||
try:
|
||||
while True:
|
||||
data = await websocket.receive_text()
|
||||
try:
|
||||
msg = json.loads(data)
|
||||
cmd_type = msg.get("type")
|
||||
|
||||
if cmd_type == "SET_TARGET":
|
||||
x = msg.get("x", 0.0)
|
||||
y = msg.get("y", 8.0)
|
||||
z = msg.get("z", 0.0)
|
||||
engine.set_target(x, y, z)
|
||||
|
||||
elif cmd_type == "SET_FORMATION":
|
||||
if "mode" in msg:
|
||||
engine.formation_mode = msg["mode"]
|
||||
if "distance" in msg:
|
||||
engine.formation_distance = float(msg["distance"])
|
||||
if "height_offset" in msg:
|
||||
engine.formation_height_offset = float(msg["height_offset"])
|
||||
|
||||
elif cmd_type == "SET_AUTONOMOUS":
|
||||
engine.autonomous_mode = bool(msg.get("enabled", False))
|
||||
if "pattern" in msg:
|
||||
engine.auto_pattern = msg["pattern"]
|
||||
|
||||
elif cmd_type == "SET_SPEED":
|
||||
engine.speed_multiplier = float(msg.get("speed", 1.0))
|
||||
|
||||
elif cmd_type == "RESET_POSITION":
|
||||
engine.leader.pos = Vector3(0.0, 8.0, 0.0)
|
||||
engine.leader.target_pos = Vector3(0.0, 8.0, 0.0)
|
||||
engine.leader.vel = Vector3(0.0, 0.0, 0.0)
|
||||
# Reset wingmen around leader
|
||||
for idx, d in enumerate(engine.drones[1:], 1):
|
||||
d.pos = Vector3(idx * 4.0, 8.0, -idx * 3.0)
|
||||
d.vel = Vector3(0.0, 0.0, 0.0)
|
||||
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
except WebSocketDisconnect:
|
||||
if websocket in engine.clients:
|
||||
engine.clients.remove(websocket)
|
||||
except Exception as e:
|
||||
if websocket in engine.clients:
|
||||
engine.clients.remove(websocket)
|
||||
|
||||
|
||||
async def physics_loop():
|
||||
"""High-frequency background simulation loop (60 FPS)"""
|
||||
last_time = time.time()
|
||||
while True:
|
||||
now = time.time()
|
||||
dt = min(0.05, now - last_time)
|
||||
last_time = now
|
||||
|
||||
engine.update_physics(dt)
|
||||
|
||||
if engine.clients:
|
||||
payload = json.dumps(engine.get_state_payload())
|
||||
dead_clients = []
|
||||
for client in engine.clients:
|
||||
try:
|
||||
await client.send_text(payload)
|
||||
except Exception:
|
||||
dead_clients.append(client)
|
||||
for dead in dead_clients:
|
||||
if dead in engine.clients:
|
||||
engine.clients.remove(dead)
|
||||
|
||||
await asyncio.sleep(1.0 / 60.0)
|
||||
|
||||
|
||||
@contextlib.asynccontextmanager
|
||||
async def lifespan(app):
|
||||
sim_task = asyncio.create_task(physics_loop())
|
||||
yield
|
||||
sim_task.cancel()
|
||||
try:
|
||||
await sim_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
|
||||
routes = [
|
||||
Route("/", homepage),
|
||||
WebSocketRoute("/ws", websocket_endpoint),
|
||||
Route("/api/status", api_status),
|
||||
Mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
|
||||
]
|
||||
|
||||
app = Starlette(debug=True, routes=routes, lifespan=lifespan)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if hasattr(sys.stdout, "reconfigure"):
|
||||
try:
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
except Exception:
|
||||
pass
|
||||
print("================================================================")
|
||||
print(" [5-DRONE FORMATION FLIGHT SIMULATOR] Three.js + Python")
|
||||
print(" Server starting at: http://127.0.0.1:8000")
|
||||
print("================================================================")
|
||||
uvicorn.run("app:app", host="127.0.0.1", port=8000, log_level="info", reload=False)
|
||||
@@ -0,0 +1,3 @@
|
||||
starlette>=0.30.0
|
||||
uvicorn>=0.23.0
|
||||
websockets>=11.0.0
|
||||
@@ -0,0 +1,18 @@
|
||||
@echo off
|
||||
chcp 65001 > nul
|
||||
cls
|
||||
echo ========================================================
|
||||
echo [5-DRONE SWARM FORMATION FLIGHT SIMULATOR]
|
||||
echo Three.js + Python Starlette + WebSockets
|
||||
echo URL: http://127.0.0.1:8000
|
||||
echo ========================================================
|
||||
cd /d "%~dp0"
|
||||
|
||||
if exist "..\..\.venv\Scripts\python.exe" (
|
||||
..\..\.venv\Scripts\python.exe app.py
|
||||
) else if exist "..\.venv\Scripts\python.exe" (
|
||||
..\.venv\Scripts\python.exe app.py
|
||||
) else (
|
||||
python app.py
|
||||
)
|
||||
pause
|
||||
@@ -0,0 +1,597 @@
|
||||
/* ============================================================
|
||||
DRONE FORMATION FLIGHT SIMULATOR - TACTICAL HUD STYLESHEET
|
||||
============================================================ */
|
||||
|
||||
@import url('https://fonts.googleapis.com/css2?family=Orbitron:wght@400;600;800;900&family=Rajdhani:wght@500;600;700&family=Share+Tech+Mono&display=swap');
|
||||
|
||||
:root {
|
||||
--bg-dark: #070a0f;
|
||||
--panel-bg: rgba(12, 18, 28, 0.72);
|
||||
--panel-border: rgba(0, 240, 255, 0.28);
|
||||
--panel-border-glow: rgba(0, 240, 255, 0.45);
|
||||
|
||||
--color-cyan: #00f0ff;
|
||||
--color-pink: #ff0055;
|
||||
--color-green: #00ff88;
|
||||
--color-amber: #ffb700;
|
||||
--color-text-main: #d8e5f2;
|
||||
--color-text-dim: #7a8fa6;
|
||||
|
||||
--font-head: 'Orbitron', sans-serif;
|
||||
--font-ui: 'Rajdhani', sans-serif;
|
||||
--font-mono: 'Share Tech Mono', monospace;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--bg-dark);
|
||||
color: var(--color-text-main);
|
||||
font-family: var(--font-ui);
|
||||
overflow: hidden;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
#canvas-container {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Vignette & scanline overlay */
|
||||
.hud-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
background: radial-gradient(circle at center, transparent 60%, rgba(3, 7, 12, 0.75) 100%);
|
||||
box-shadow: inset 0 0 100px rgba(0, 240, 255, 0.05);
|
||||
}
|
||||
|
||||
.hud-overlay::before {
|
||||
content: " ";
|
||||
position: absolute;
|
||||
top: 0; left: 0; width: 100%; height: 100%;
|
||||
background: linear-gradient(rgba(18, 16, 16, 0) 50%, rgba(0, 0, 0, 0.25) 50%);
|
||||
background-size: 100% 4px;
|
||||
opacity: 0.2;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ================= HUD TOP HEADER ================= */
|
||||
.top-nav {
|
||||
position: absolute;
|
||||
top: 14px;
|
||||
left: 20px;
|
||||
right: 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
z-index: 10;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
background: var(--panel-bg);
|
||||
border: 1px solid var(--panel-border);
|
||||
backdrop-filter: blur(14px);
|
||||
padding: 8px 18px;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5), inset 0 0 10px rgba(0, 240, 255, 0.1);
|
||||
}
|
||||
|
||||
.brand-icon {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: var(--color-cyan);
|
||||
box-shadow: 0 0 12px var(--color-cyan);
|
||||
border-radius: 2px;
|
||||
animation: pulseGlow 2s infinite alternate;
|
||||
}
|
||||
|
||||
.brand-title h1 {
|
||||
font-family: var(--font-head);
|
||||
font-size: 1.05rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 2px;
|
||||
color: #fff;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.brand-title span {
|
||||
color: var(--color-cyan);
|
||||
font-size: 0.8rem;
|
||||
font-family: var(--font-mono);
|
||||
border-left: 1px solid var(--color-text-dim);
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
.top-status-bar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
background: var(--panel-bg);
|
||||
border: 1px solid var(--panel-border);
|
||||
backdrop-filter: blur(14px);
|
||||
padding: 6px 14px;
|
||||
border-radius: 4px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.85rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-green);
|
||||
box-shadow: 0 0 8px var(--color-green);
|
||||
}
|
||||
|
||||
.status-dot.warning {
|
||||
background: var(--color-amber);
|
||||
box-shadow: 0 0 8px var(--color-amber);
|
||||
}
|
||||
|
||||
/* ================= MAIN SIDE PANELS ================= */
|
||||
.side-panel {
|
||||
position: absolute;
|
||||
top: 72px;
|
||||
bottom: 86px;
|
||||
width: 320px;
|
||||
background: var(--panel-bg);
|
||||
border: 1px solid var(--panel-border);
|
||||
backdrop-filter: blur(16px);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.6);
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--color-cyan) transparent;
|
||||
}
|
||||
|
||||
.side-panel::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
.side-panel::-webkit-scrollbar-thumb {
|
||||
background: var(--color-cyan);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.left-panel {
|
||||
left: 20px;
|
||||
}
|
||||
|
||||
.right-panel {
|
||||
right: 20px;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid rgba(0, 240, 255, 0.2);
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-family: var(--font-head);
|
||||
font-size: 0.88rem;
|
||||
letter-spacing: 1.5px;
|
||||
color: var(--color-cyan);
|
||||
text-transform: uppercase;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* Drone Telemetry Cards */
|
||||
.drone-card {
|
||||
background: rgba(18, 25, 38, 0.7);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 6px;
|
||||
padding: 8px 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.drone-card.leader {
|
||||
border-left: 4px solid var(--color-cyan);
|
||||
}
|
||||
|
||||
.card-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.drone-name {
|
||||
font-family: var(--font-head);
|
||||
font-weight: 700;
|
||||
font-size: 0.82rem;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.callsign-tag {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.7rem;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.telemetry-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.tele-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: rgba(7, 12, 18, 0.5);
|
||||
padding: 4px 6px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.tele-label {
|
||||
font-size: 0.65rem;
|
||||
color: var(--color-text-dim);
|
||||
font-family: var(--font-mono);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.tele-val {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
|
||||
.progress-bar-bg {
|
||||
width: 100%;
|
||||
height: 5px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.progress-bar-fill {
|
||||
height: 100%;
|
||||
width: 90%;
|
||||
background: var(--color-green);
|
||||
border-radius: 3px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
/* Formation Distance Gauge */
|
||||
.formation-gauge-card {
|
||||
background: rgba(18, 25, 38, 0.7);
|
||||
border: 1px solid rgba(0, 240, 255, 0.2);
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.gauge-title {
|
||||
font-family: var(--font-head);
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 1px;
|
||||
color: var(--color-text-dim);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.gauge-distance {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 1.6rem;
|
||||
font-weight: 900;
|
||||
color: var(--color-cyan);
|
||||
text-shadow: 0 0 10px rgba(0, 240, 255, 0.5);
|
||||
}
|
||||
|
||||
.formation-status-text {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-green);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* ================= RIGHT PANEL CONTROLS ================= */
|
||||
.radar-box {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background: rgba(7, 11, 18, 0.6);
|
||||
border: 1px solid rgba(0, 240, 255, 0.2);
|
||||
border-radius: 6px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
#tactical-radar {
|
||||
display: block;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.control-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.control-label {
|
||||
font-family: var(--font-head);
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 1px;
|
||||
color: var(--color-text-dim);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.formation-buttons {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.btn-formation {
|
||||
background: rgba(18, 25, 38, 0.8);
|
||||
border: 1px solid rgba(0, 240, 255, 0.2);
|
||||
color: var(--color-text-main);
|
||||
padding: 7px 4px;
|
||||
border-radius: 4px;
|
||||
font-family: var(--font-ui);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.btn-formation:hover {
|
||||
background: rgba(0, 240, 255, 0.15);
|
||||
border-color: var(--color-cyan);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-formation.active {
|
||||
background: var(--color-cyan);
|
||||
border-color: var(--color-cyan);
|
||||
color: #050b14;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 0 12px rgba(0, 240, 255, 0.4);
|
||||
}
|
||||
|
||||
.slider-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.slider-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
input[type="range"] {
|
||||
-webkit-appearance: none;
|
||||
width: 100%;
|
||||
height: 5px;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 3px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-cyan);
|
||||
cursor: pointer;
|
||||
box-shadow: 0 0 8px var(--color-cyan);
|
||||
}
|
||||
|
||||
.toggle-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: rgba(18, 25, 38, 0.6);
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 44px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.switch input { opacity: 0; width: 0; height: 0; }
|
||||
|
||||
.slider-toggle {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
transition: .3s;
|
||||
border-radius: 22px;
|
||||
}
|
||||
|
||||
.slider-toggle:before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
background-color: white;
|
||||
transition: .3s;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
input:checked + .slider-toggle {
|
||||
background-color: var(--color-cyan);
|
||||
}
|
||||
|
||||
input:checked + .slider-toggle:before {
|
||||
transform: translateX(22px);
|
||||
background-color: #070a0f;
|
||||
}
|
||||
|
||||
/* ================= BOTTOM FLIGHT CONTROL BAR ================= */
|
||||
.bottom-bar {
|
||||
position: absolute;
|
||||
bottom: 16px;
|
||||
left: 20px;
|
||||
right: 20px;
|
||||
background: var(--panel-bg);
|
||||
border: 1px solid var(--panel-border);
|
||||
backdrop-filter: blur(16px);
|
||||
border-radius: 8px;
|
||||
padding: 10px 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
z-index: 10;
|
||||
box-shadow: 0 4px 25px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.flight-instruct {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.instruct-pill {
|
||||
background: rgba(0, 240, 255, 0.1);
|
||||
border: 1px solid rgba(0, 240, 255, 0.3);
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
color: var(--color-cyan);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.cam-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn-cam {
|
||||
background: rgba(18, 25, 38, 0.8);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
color: var(--color-text-main);
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
font-family: var(--font-head);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-cam:hover {
|
||||
border-color: var(--color-cyan);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-cam.active {
|
||||
background: var(--color-cyan);
|
||||
border-color: var(--color-cyan);
|
||||
color: #050b14;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 0 10px rgba(0, 240, 255, 0.4);
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn-action {
|
||||
background: rgba(255, 0, 85, 0.15);
|
||||
border: 1px solid rgba(255, 0, 85, 0.4);
|
||||
color: #ff5588;
|
||||
padding: 6px 14px;
|
||||
border-radius: 4px;
|
||||
font-family: var(--font-head);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-action:hover {
|
||||
background: rgba(255, 0, 85, 0.3);
|
||||
color: #fff;
|
||||
box-shadow: 0 0 10px rgba(255, 0, 85, 0.4);
|
||||
}
|
||||
|
||||
/* Mouse targeting 3D reticle indicator */
|
||||
.target-crosshair {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 1px dashed var(--color-cyan);
|
||||
border-radius: 50%;
|
||||
z-index: 5;
|
||||
opacity: 0.8;
|
||||
transition: width 0.15s, height 0.15s;
|
||||
}
|
||||
|
||||
.target-crosshair::before, .target-crosshair::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
background: var(--color-cyan);
|
||||
}
|
||||
.target-crosshair::before { top: 50%; left: -6px; right: -6px; height: 1px; transform: translateY(-50%); }
|
||||
.target-crosshair::after { left: 50%; top: -6px; bottom: -6px; width: 1px; transform: translateX(-50%); }
|
||||
|
||||
@keyframes pulseGlow {
|
||||
from { box-shadow: 0 0 6px var(--color-cyan); }
|
||||
to { box-shadow: 0 0 18px var(--color-cyan); }
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.side-panel { width: 260px; }
|
||||
.flight-instruct { display: none; }
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>드론 5대 군집 편대 비행 시뮬레이터 | 5-Drone Swarm Formation (Three.js & Python)</title>
|
||||
|
||||
<!-- Three.js and OrbitControls -->
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js"></script>
|
||||
|
||||
<!-- Custom CSS -->
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- 3D WebGL Canvas Container -->
|
||||
<div id="canvas-container"></div>
|
||||
<div class="hud-overlay"></div>
|
||||
|
||||
<!-- Top Navigation & Status Bar -->
|
||||
<header class="top-nav">
|
||||
<div class="brand-title">
|
||||
<div class="brand-icon"></div>
|
||||
<h1>AERO-SWARM TACTICAL HUD</h1>
|
||||
<span>5-DRONE SWARM FORMATION CONTROLLER</span>
|
||||
</div>
|
||||
|
||||
<div class="top-status-bar">
|
||||
<div class="status-badge">
|
||||
<div id="connection-status-dot" class="status-dot"></div>
|
||||
<span id="connection-status-text">CONNECTING...</span>
|
||||
</div>
|
||||
<div class="status-badge">
|
||||
<span style="color: var(--color-cyan);">SWARM:</span>
|
||||
<span style="color: #fff; font-weight: bold;">5 UNITS ACTIVE</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Left Side Panel: Real-Time 5-Drone Swarm Telemetry -->
|
||||
<aside class="side-panel left-panel">
|
||||
<div class="panel-header">
|
||||
<div class="panel-title">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/></svg>
|
||||
SWARM TELEMETRY (5 UNITS)
|
||||
</div>
|
||||
<span class="callsign-tag" style="color: var(--color-cyan);">60 FPS SYNC</span>
|
||||
</div>
|
||||
|
||||
<!-- 1. Leader Drone -->
|
||||
<div class="drone-card leader">
|
||||
<div class="card-top">
|
||||
<span class="drone-name" style="color: #00f0ff;">LEADER (VIPER-01)</span>
|
||||
<span class="callsign-tag">ALPHA-01</span>
|
||||
</div>
|
||||
<div class="telemetry-grid">
|
||||
<div class="tele-item">
|
||||
<span class="tele-label">POS</span>
|
||||
<span id="tele-d0-pos" class="tele-val">0.0, 8.0, 0.0</span>
|
||||
</div>
|
||||
<div class="tele-item">
|
||||
<span class="tele-label">AIRSPEED</span>
|
||||
<span id="tele-d0-spd" class="tele-val" style="color: #00f0ff;">0.0 km/h</span>
|
||||
</div>
|
||||
<div class="tele-item">
|
||||
<span class="tele-label">ALTITUDE</span>
|
||||
<span id="tele-d0-alt" class="tele-val">8.0 m</span>
|
||||
</div>
|
||||
<div class="tele-item">
|
||||
<span class="tele-label">BATTERY</span>
|
||||
<span id="tele-d0-bat" class="tele-val" style="color: var(--color-green);">98.5%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="progress-bar-bg"><div id="bar-d0-bat" class="progress-bar-fill"></div></div>
|
||||
</div>
|
||||
|
||||
<!-- 2. Wingman 1 -->
|
||||
<div class="drone-card" style="border-left: 4px solid #ff0055;">
|
||||
<div class="card-top">
|
||||
<span class="drone-name" style="color: #ff0055;">WINGMAN 1 (GHOST-02)</span>
|
||||
<span class="callsign-tag">ALPHA-02</span>
|
||||
</div>
|
||||
<div class="telemetry-grid">
|
||||
<div class="tele-item">
|
||||
<span class="tele-label">POS</span>
|
||||
<span id="tele-d1-pos" class="tele-val">5.0, 8.0, -5.0</span>
|
||||
</div>
|
||||
<div class="tele-item">
|
||||
<span class="tele-label">AIRSPEED</span>
|
||||
<span id="tele-d1-spd" class="tele-val" style="color: #ff0055;">0.0 km/h</span>
|
||||
</div>
|
||||
<div class="tele-item">
|
||||
<span class="tele-label">ALTITUDE</span>
|
||||
<span id="tele-d1-alt" class="tele-val">8.0 m</span>
|
||||
</div>
|
||||
<div class="tele-item">
|
||||
<span class="tele-label">BATTERY</span>
|
||||
<span id="tele-d1-bat" class="tele-val" style="color: var(--color-green);">98.5%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="progress-bar-bg"><div id="bar-d1-bat" class="progress-bar-fill"></div></div>
|
||||
</div>
|
||||
|
||||
<!-- 3. Wingman 2 -->
|
||||
<div class="drone-card" style="border-left: 4px solid #00ff88;">
|
||||
<div class="card-top">
|
||||
<span class="drone-name" style="color: #00ff88;">WINGMAN 2 (PHANTOM-03)</span>
|
||||
<span class="callsign-tag">ALPHA-03</span>
|
||||
</div>
|
||||
<div class="telemetry-grid">
|
||||
<div class="tele-item">
|
||||
<span class="tele-label">POS</span>
|
||||
<span id="tele-d2-pos" class="tele-val">-5.0, 8.0, -5.0</span>
|
||||
</div>
|
||||
<div class="tele-item">
|
||||
<span class="tele-label">AIRSPEED</span>
|
||||
<span id="tele-d2-spd" class="tele-val" style="color: #00ff88;">0.0 km/h</span>
|
||||
</div>
|
||||
<div class="tele-item">
|
||||
<span class="tele-label">ALTITUDE</span>
|
||||
<span id="tele-d2-alt" class="tele-val">8.0 m</span>
|
||||
</div>
|
||||
<div class="tele-item">
|
||||
<span class="tele-label">BATTERY</span>
|
||||
<span id="tele-d2-bat" class="tele-val" style="color: var(--color-green);">98.5%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="progress-bar-bg"><div id="bar-d2-bat" class="progress-bar-fill"></div></div>
|
||||
</div>
|
||||
|
||||
<!-- 4. Wingman 3 -->
|
||||
<div class="drone-card" style="border-left: 4px solid #ffb700;">
|
||||
<div class="card-top">
|
||||
<span class="drone-name" style="color: #ffb700;">WINGMAN 3 (SPECTER-04)</span>
|
||||
<span class="callsign-tag">ALPHA-04</span>
|
||||
</div>
|
||||
<div class="telemetry-grid">
|
||||
<div class="tele-item">
|
||||
<span class="tele-label">POS</span>
|
||||
<span id="tele-d3-pos" class="tele-val">10.0, 8.0, -10.0</span>
|
||||
</div>
|
||||
<div class="tele-item">
|
||||
<span class="tele-label">AIRSPEED</span>
|
||||
<span id="tele-d3-spd" class="tele-val" style="color: #ffb700;">0.0 km/h</span>
|
||||
</div>
|
||||
<div class="tele-item">
|
||||
<span class="tele-label">ALTITUDE</span>
|
||||
<span id="tele-d3-alt" class="tele-val">8.0 m</span>
|
||||
</div>
|
||||
<div class="tele-item">
|
||||
<span class="tele-label">BATTERY</span>
|
||||
<span id="tele-d3-bat" class="tele-val" style="color: var(--color-green);">98.5%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="progress-bar-bg"><div id="bar-d3-bat" class="progress-bar-fill"></div></div>
|
||||
</div>
|
||||
|
||||
<!-- 5. Wingman 4 -->
|
||||
<div class="drone-card" style="border-left: 4px solid #b026ff;">
|
||||
<div class="card-top">
|
||||
<span class="drone-name" style="color: #b026ff;">WINGMAN 4 (SHADOW-05)</span>
|
||||
<span class="callsign-tag">ALPHA-05</span>
|
||||
</div>
|
||||
<div class="telemetry-grid">
|
||||
<div class="tele-item">
|
||||
<span class="tele-label">POS</span>
|
||||
<span id="tele-d4-pos" class="tele-val">-10.0, 8.0, -10.0</span>
|
||||
</div>
|
||||
<div class="tele-item">
|
||||
<span class="tele-label">AIRSPEED</span>
|
||||
<span id="tele-d4-spd" class="tele-val" style="color: #b026ff;">0.0 km/h</span>
|
||||
</div>
|
||||
<div class="tele-item">
|
||||
<span class="tele-label">ALTITUDE</span>
|
||||
<span id="tele-d4-alt" class="tele-val">8.0 m</span>
|
||||
</div>
|
||||
<div class="tele-item">
|
||||
<span class="tele-label">BATTERY</span>
|
||||
<span id="tele-d4-bat" class="tele-val" style="color: var(--color-green);">98.5%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="progress-bar-bg"><div id="bar-d4-bat" class="progress-bar-fill"></div></div>
|
||||
</div>
|
||||
|
||||
<!-- Formation Distance Status -->
|
||||
<div class="formation-gauge-card">
|
||||
<div class="gauge-title">FORMATION BASE SPACING</div>
|
||||
<div id="gauge-dist-val" class="gauge-distance">6.0m</div>
|
||||
<div id="formation-status-msg" class="formation-status-text">5-DRONE SWARM: V_FORMATION</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Right Side Panel: Radar & Tactical Formation Controls -->
|
||||
<aside class="side-panel right-panel">
|
||||
<div class="panel-header">
|
||||
<div class="panel-title">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="12" r="10"/></svg>
|
||||
TACTICAL RADAR & FORMATION
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2D Aerial Radar Scope -->
|
||||
<div class="radar-box">
|
||||
<canvas id="tactical-radar" width="220" height="220"></canvas>
|
||||
</div>
|
||||
|
||||
<!-- 5-Drone Formation Presets -->
|
||||
<div class="control-group">
|
||||
<div class="control-label">편대 대형 선택 (5-Drone Formation)</div>
|
||||
<div class="formation-buttons">
|
||||
<button class="btn-formation active" data-mode="V_FORMATION">V 편대 (V-Formation)</button>
|
||||
<button class="btn-formation" data-mode="DIAMOND">다이아몬드 (Diamond)</button>
|
||||
<button class="btn-formation" data-mode="SIDE_BY_SIDE">횡대 일렬 (Line Abreast)</button>
|
||||
<button class="btn-formation" data-mode="TANDEM">종대 직렬 (Column / Trail)</button>
|
||||
<button class="btn-formation" data-mode="ORBIT">군집 선회 (Swarm Orbit)</button>
|
||||
<button class="btn-formation" data-mode="ECHELON_RIGHT">사선 편대 (Echelon Right)</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Spacing / Distance Slider -->
|
||||
<div class="control-group">
|
||||
<div class="slider-row">
|
||||
<div class="slider-info">
|
||||
<span class="control-label">편대 기본 간격 (Spacing)</span>
|
||||
<span id="dist-value" style="color: var(--color-cyan); font-weight: bold;">6.0m</span>
|
||||
</div>
|
||||
<input type="range" id="formation-dist-slider" min="2.5" max="25.0" step="0.5" value="6.0">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Formation Altitude Offset Slider -->
|
||||
<div class="control-group">
|
||||
<div class="slider-row">
|
||||
<div class="slider-info">
|
||||
<span class="control-label">윙맨 고도 편차 (Alt Offset)</span>
|
||||
<span id="height-value" style="color: var(--color-pink); font-weight: bold;">0.0m</span>
|
||||
</div>
|
||||
<input type="range" id="formation-height-slider" min="-8.0" max="8.0" step="0.5" value="0.0">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Flight Altitude Slider -->
|
||||
<div class="control-group">
|
||||
<div class="slider-row">
|
||||
<div class="slider-info">
|
||||
<span class="control-label">비행 목표 고도 (Flight Altitude)</span>
|
||||
<span id="alt-value" style="color: var(--color-cyan); font-weight: bold;">8.0m</span>
|
||||
</div>
|
||||
<input type="range" id="altitude-slider" min="2.0" max="35.0" step="0.5" value="8.0">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Speed Multiplier Slider -->
|
||||
<div class="control-group">
|
||||
<div class="slider-row">
|
||||
<div class="slider-info">
|
||||
<span class="control-label">비행 속도 배율 (Speed Agility)</span>
|
||||
<span id="speed-value" style="color: var(--color-green); font-weight: bold;">1.0x</span>
|
||||
</div>
|
||||
<input type="range" id="speed-slider" min="0.4" max="2.5" step="0.1" value="1.0">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Autonomous Patrol Toggle -->
|
||||
<div class="control-group">
|
||||
<div class="toggle-row">
|
||||
<span class="control-label" style="color: #fff;">군집 자율 정찰 (Auto Patrol)</span>
|
||||
<label class="switch">
|
||||
<input type="checkbox" id="auto-flight-toggle">
|
||||
<span class="slider-toggle"></span>
|
||||
</label>
|
||||
</div>
|
||||
<select id="auto-pattern-select" style="width: 100%; background: #0c1420; color: #00f0ff; border: 1px solid var(--panel-border); padding: 6px; border-radius: 4px; font-family: var(--font-mono); outline: none;">
|
||||
<option value="FIGURE_EIGHT">8자 기동 (Figure-8 Aero)</option>
|
||||
<option value="CIRCLE">원형 선회 (Circular Orbit)</option>
|
||||
<option value="WAYPOINT_PATROL">다이아몬드 순찰 (Diamond Recon)</option>
|
||||
</select>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Bottom Flight Control Bar & Camera Director -->
|
||||
<footer class="bottom-bar">
|
||||
<div class="flight-instruct">
|
||||
<span class="instruct-pill">🖱️ 마우스 클릭/드래그</span>
|
||||
<span>화면을 마우스로 클릭하면 리더 드론을 필두로 드론 5대가 편대를 유지하며 이동합니다 (휠/W,S: 고도 변경)</span>
|
||||
</div>
|
||||
|
||||
<!-- Camera Switcher -->
|
||||
<div class="cam-buttons">
|
||||
<button class="btn-cam active" data-cam="ORBIT">🎥 군집 궤도뷰 [1]</button>
|
||||
<button class="btn-cam" data-cam="CHASE">🚀 리더 추적뷰 [2]</button>
|
||||
<button class="btn-cam" data-cam="FPV">🎯 조종석 FPV [3]</button>
|
||||
<button class="btn-cam" data-cam="FORMATION">📐 편대 프로필 [4]</button>
|
||||
</div>
|
||||
|
||||
<!-- Sound & Action buttons -->
|
||||
<div class="action-buttons">
|
||||
<button id="btn-audio-toggle" class="btn-action" style="color: var(--color-cyan); border-color: rgba(0,240,255,0.4); background: rgba(0,240,255,0.1);">🔊 SFX OFF [M]</button>
|
||||
<button id="btn-reset" class="btn-action">🔄 위치 초기화 [Space]</button>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- Scripts -->
|
||||
<script src="/static/js/audio.js"></script>
|
||||
<script src="/static/js/radar.js"></script>
|
||||
<script src="/static/js/drone_model.js"></script>
|
||||
<script src="/static/js/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Synthesizer for Drone Rotor Hum and Tactical HUD Audio FX
|
||||
* Uses Web Audio API without external audio file dependencies
|
||||
*/
|
||||
class DroneAudioSystem {
|
||||
constructor() {
|
||||
this.ctx = null;
|
||||
this.enabled = false;
|
||||
this.leaderGain = null;
|
||||
this.followerGain = null;
|
||||
this.leaderOsc1 = null;
|
||||
this.leaderOsc2 = null;
|
||||
this.followerOsc1 = null;
|
||||
this.followerOsc2 = null;
|
||||
}
|
||||
|
||||
init() {
|
||||
if (this.ctx) return;
|
||||
try {
|
||||
const AudioContext = window.AudioContext || window.webkitAudioContext;
|
||||
this.ctx = new AudioContext();
|
||||
|
||||
// Master compressor to prevent clipping
|
||||
const compressor = this.ctx.createDynamicsCompressor();
|
||||
compressor.threshold.setValueAtTime(-18, this.ctx.currentTime);
|
||||
compressor.knee.setValueAtTime(30, this.ctx.currentTime);
|
||||
compressor.ratio.setValueAtTime(12, this.ctx.currentTime);
|
||||
compressor.attack.setValueAtTime(0.003, this.ctx.currentTime);
|
||||
compressor.release.setValueAtTime(0.25, this.ctx.currentTime);
|
||||
compressor.connect(this.ctx.destination);
|
||||
|
||||
this.masterGain = this.ctx.createGain();
|
||||
this.masterGain.gain.setValueAtTime(0.2, this.ctx.currentTime);
|
||||
this.masterGain.connect(compressor);
|
||||
|
||||
// Leader Drone sound synthesizer (Dual harmonic oscillator + lowpass filter)
|
||||
this.leaderFilter = this.ctx.createBiquadFilter();
|
||||
this.leaderFilter.type = "lowpass";
|
||||
this.leaderFilter.frequency.setValueAtTime(650, this.ctx.currentTime);
|
||||
|
||||
this.leaderGain = this.ctx.createGain();
|
||||
this.leaderGain.gain.setValueAtTime(0.0, this.ctx.currentTime);
|
||||
this.leaderGain.connect(this.leaderFilter);
|
||||
this.leaderFilter.connect(this.masterGain);
|
||||
|
||||
this.leaderOsc1 = this.ctx.createOscillator();
|
||||
this.leaderOsc1.type = "sawtooth";
|
||||
this.leaderOsc1.frequency.setValueAtTime(95, this.ctx.currentTime);
|
||||
|
||||
this.leaderOsc2 = this.ctx.createOscillator();
|
||||
this.leaderOsc2.type = "triangle";
|
||||
this.leaderOsc2.frequency.setValueAtTime(190, this.ctx.currentTime);
|
||||
|
||||
this.leaderOsc1.connect(this.leaderGain);
|
||||
this.leaderOsc2.connect(this.leaderGain);
|
||||
this.leaderOsc1.start();
|
||||
this.leaderOsc2.start();
|
||||
|
||||
// Follower Drone sound synthesizer
|
||||
this.followerFilter = this.ctx.createBiquadFilter();
|
||||
this.followerFilter.type = "lowpass";
|
||||
this.followerFilter.frequency.setValueAtTime(600, this.ctx.currentTime);
|
||||
|
||||
this.followerGain = this.ctx.createGain();
|
||||
this.followerGain.gain.setValueAtTime(0.0, this.ctx.currentTime);
|
||||
this.followerGain.connect(this.followerFilter);
|
||||
this.followerFilter.connect(this.masterGain);
|
||||
|
||||
this.followerOsc1 = this.ctx.createOscillator();
|
||||
this.followerOsc1.type = "sawtooth";
|
||||
this.followerOsc1.frequency.setValueAtTime(98, this.ctx.currentTime);
|
||||
|
||||
this.followerOsc2 = this.ctx.createOscillator();
|
||||
this.followerOsc2.type = "triangle";
|
||||
this.followerOsc2.frequency.setValueAtTime(196, this.ctx.currentTime);
|
||||
|
||||
this.followerOsc1.connect(this.followerGain);
|
||||
this.followerOsc2.connect(this.followerGain);
|
||||
this.followerOsc1.start();
|
||||
this.followerOsc2.start();
|
||||
|
||||
this.enabled = true;
|
||||
} catch (e) {
|
||||
console.warn("Web Audio not supported or blocked", e);
|
||||
}
|
||||
}
|
||||
|
||||
toggle() {
|
||||
if (!this.ctx) {
|
||||
this.init();
|
||||
return true;
|
||||
}
|
||||
if (this.ctx.state === 'suspended') {
|
||||
this.ctx.resume();
|
||||
this.enabled = true;
|
||||
return true;
|
||||
}
|
||||
this.enabled = !this.enabled;
|
||||
if (!this.enabled && this.leaderGain && this.followerGain) {
|
||||
this.leaderGain.gain.setValueAtTime(0.0, this.ctx.currentTime);
|
||||
this.followerGain.gain.setValueAtTime(0.0, this.ctx.currentTime);
|
||||
}
|
||||
return this.enabled;
|
||||
}
|
||||
|
||||
updateMotors(leaderRpm, followerRpm, speedLeader, speedFollower) {
|
||||
if (!this.enabled || !this.ctx || this.ctx.state !== 'running') return;
|
||||
|
||||
const now = this.ctx.currentTime;
|
||||
// Pitch proportional to RPM and velocity
|
||||
const baseFreq1 = 85 + (speedLeader * 2.5) + ((leaderRpm - 4500) * 0.015);
|
||||
const baseFreq2 = 88 + (speedFollower * 2.5) + ((followerRpm - 4500) * 0.015);
|
||||
|
||||
this.leaderOsc1.frequency.setTargetAtTime(Math.max(60, Math.min(220, baseFreq1)), now, 0.08);
|
||||
this.leaderOsc2.frequency.setTargetAtTime(Math.max(120, Math.min(440, baseFreq1 * 2)), now, 0.08);
|
||||
|
||||
this.followerOsc1.frequency.setTargetAtTime(Math.max(60, Math.min(220, baseFreq2)), now, 0.08);
|
||||
this.followerOsc2.frequency.setTargetAtTime(Math.max(120, Math.min(440, baseFreq2 * 2)), now, 0.08);
|
||||
|
||||
this.leaderGain.gain.setTargetAtTime(0.08, now, 0.1);
|
||||
this.followerGain.gain.setTargetAtTime(0.08, now, 0.1);
|
||||
}
|
||||
|
||||
playBeep(freq = 880, duration = 0.08) {
|
||||
if (!this.enabled || !this.ctx) return;
|
||||
try {
|
||||
const osc = this.ctx.createOscillator();
|
||||
const gain = this.ctx.createGain();
|
||||
osc.type = "sine";
|
||||
osc.frequency.setValueAtTime(freq, this.ctx.currentTime);
|
||||
gain.gain.setValueAtTime(0.08, this.ctx.currentTime);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, this.ctx.currentTime + duration);
|
||||
osc.connect(gain);
|
||||
gain.connect(this.masterGain);
|
||||
osc.start();
|
||||
osc.stop(this.ctx.currentTime + duration);
|
||||
} catch (e) {}
|
||||
}
|
||||
}
|
||||
|
||||
window.droneAudio = new DroneAudioSystem();
|
||||
@@ -0,0 +1,319 @@
|
||||
/**
|
||||
* Procedural 3D Quadcopter Drone Builder in Three.js
|
||||
* Generates high-fidelity carbon-fiber military/tactical UAV models with animated rotors,
|
||||
* navigation LEDs, gimbal camera, landing skids, and thrust trail effects.
|
||||
*/
|
||||
|
||||
class DroneModel {
|
||||
constructor(scene, options = {}) {
|
||||
this.scene = scene;
|
||||
this.id = options.id || "drone";
|
||||
this.name = options.name || "UAV";
|
||||
this.primaryColor = options.primaryColor || 0x00f0ff;
|
||||
this.accentColor = options.accentColor || 0x111625;
|
||||
this.isLeader = !!options.isLeader;
|
||||
|
||||
this.group = new THREE.Group();
|
||||
this.rotors = [];
|
||||
this.rotorDiscs = [];
|
||||
this.strobeLeds = [];
|
||||
this.thrustParticles = [];
|
||||
|
||||
this._buildModel();
|
||||
this.scene.add(this.group);
|
||||
}
|
||||
|
||||
_buildModel() {
|
||||
// High quality PBR materials
|
||||
const carbonMat = new THREE.MeshStandardMaterial({
|
||||
color: 0x1a1e24,
|
||||
metalness: 0.85,
|
||||
roughness: 0.3,
|
||||
});
|
||||
|
||||
const metalMat = new THREE.MeshStandardMaterial({
|
||||
color: 0x8892a0,
|
||||
metalness: 0.95,
|
||||
roughness: 0.15,
|
||||
});
|
||||
|
||||
const canopyMat = new THREE.MeshStandardMaterial({
|
||||
color: 0x0d131a,
|
||||
metalness: 0.6,
|
||||
roughness: 0.1,
|
||||
clearcoat: 0.8,
|
||||
clearcoatRoughness: 0.1
|
||||
});
|
||||
|
||||
const accentMat = new THREE.MeshStandardMaterial({
|
||||
color: this.primaryColor,
|
||||
metalness: 0.7,
|
||||
roughness: 0.3,
|
||||
emissive: this.primaryColor,
|
||||
emissiveIntensity: 0.35
|
||||
});
|
||||
|
||||
const glowMat = new THREE.MeshBasicMaterial({
|
||||
color: this.primaryColor
|
||||
});
|
||||
|
||||
// 1. Central Body Chassis (Lower & Upper aerodynamic hull)
|
||||
const bodyGeo = new THREE.CylinderGeometry(0.7, 0.9, 0.32, 8);
|
||||
bodyGeo.rotateY(Math.PI / 8);
|
||||
const bodyMesh = new THREE.Mesh(bodyGeo, carbonMat);
|
||||
bodyMesh.castShadow = true;
|
||||
bodyMesh.receiveShadow = true;
|
||||
this.group.add(bodyMesh);
|
||||
|
||||
// Aerodynamic Top Canopy Shell
|
||||
const canopyGeo = new THREE.SphereGeometry(0.65, 16, 12);
|
||||
canopyGeo.scale(1.0, 0.45, 1.35);
|
||||
const canopyMesh = new THREE.Mesh(canopyGeo, canopyMat);
|
||||
canopyMesh.position.y = 0.18;
|
||||
canopyMesh.castShadow = true;
|
||||
this.group.add(canopyMesh);
|
||||
|
||||
// Center Accent Stripe
|
||||
const stripeGeo = new THREE.BoxGeometry(0.12, 0.04, 1.4);
|
||||
const stripeMesh = new THREE.Mesh(stripeGeo, accentMat);
|
||||
stripeMesh.position.y = 0.34;
|
||||
this.group.add(stripeMesh);
|
||||
|
||||
// 2. Gimbal Camera Sensor at Front Underbelly
|
||||
const gimbalGroup = new THREE.Group();
|
||||
gimbalGroup.position.set(0, -0.15, 0.7);
|
||||
|
||||
const gimbalBase = new THREE.Mesh(new THREE.CylinderGeometry(0.12, 0.12, 0.08, 12), metalMat);
|
||||
gimbalGroup.add(gimbalBase);
|
||||
|
||||
const cameraSphere = new THREE.Mesh(new THREE.SphereGeometry(0.16, 16, 16), carbonMat);
|
||||
cameraSphere.position.y = -0.1;
|
||||
gimbalGroup.add(cameraSphere);
|
||||
|
||||
// Camera Optical Lens (Glowing ring)
|
||||
const lensGeo = new THREE.CylinderGeometry(0.09, 0.09, 0.06, 16);
|
||||
lensGeo.rotateX(Math.PI / 2);
|
||||
const lensMesh = new THREE.Mesh(lensGeo, new THREE.MeshBasicMaterial({ color: 0x00ffff }));
|
||||
lensMesh.position.set(0, -0.1, 0.14);
|
||||
gimbalGroup.add(lensMesh);
|
||||
|
||||
this.group.add(gimbalGroup);
|
||||
|
||||
// 3. Carbon Fiber Rotor Arms (X-Configuration)
|
||||
const armLength = 1.9;
|
||||
const armRadius = 0.055;
|
||||
const armPositions = [
|
||||
{ angle: Math.PI * 0.25, dir: 1, ledColor: 0x00ff66 }, // Front-Right (Green nav)
|
||||
{ angle: Math.PI * 0.75, dir: -1, ledColor: 0xff0044 }, // Rear-Right (Red nav)
|
||||
{ angle: Math.PI * 1.25, dir: 1, ledColor: 0xff0044 }, // Rear-Left (Red nav)
|
||||
{ angle: Math.PI * 1.75, dir: -1, ledColor: 0x00ff66 }, // Front-Left (Green nav)
|
||||
];
|
||||
|
||||
this.rotors = [];
|
||||
this.rotorDiscs = [];
|
||||
|
||||
armPositions.forEach((arm, i) => {
|
||||
const armGroup = new THREE.Group();
|
||||
armGroup.rotation.y = arm.angle;
|
||||
|
||||
// Arm Carbon Tube
|
||||
const armGeo = new THREE.CylinderGeometry(armRadius, armRadius, armLength, 12);
|
||||
armGeo.rotateZ(Math.PI / 2);
|
||||
const armMesh = new THREE.Mesh(armGeo, carbonMat);
|
||||
armMesh.position.x = armLength * 0.5;
|
||||
armMesh.castShadow = true;
|
||||
armGroup.add(armMesh);
|
||||
|
||||
// Diagonal support truss
|
||||
const trussGeo = new THREE.CylinderGeometry(0.025, 0.025, armLength * 0.6, 8);
|
||||
trussGeo.rotateZ(Math.PI / 2.8);
|
||||
const trussMesh = new THREE.Mesh(trussGeo, carbonMat);
|
||||
trussMesh.position.set(armLength * 0.35, -0.1, 0);
|
||||
armGroup.add(trussMesh);
|
||||
|
||||
// Brushless Electric Motor Housing at Arm Tip
|
||||
const motorGroup = new THREE.Group();
|
||||
motorGroup.position.x = armLength;
|
||||
|
||||
const motorBase = new THREE.Mesh(new THREE.CylinderGeometry(0.18, 0.18, 0.22, 16), metalMat);
|
||||
motorBase.position.y = 0.05;
|
||||
motorBase.castShadow = true;
|
||||
motorGroup.add(motorBase);
|
||||
|
||||
// Motor Accent Ring
|
||||
const ringGeo = new THREE.TorusGeometry(0.185, 0.02, 8, 20);
|
||||
ringGeo.rotateX(Math.PI / 2);
|
||||
const ringMesh = new THREE.Mesh(ringGeo, accentMat);
|
||||
ringMesh.position.y = 0.08;
|
||||
motorGroup.add(ringMesh);
|
||||
|
||||
// Tip LED Navigation Light
|
||||
const ledGeo = new THREE.SphereGeometry(0.06, 8, 8);
|
||||
const ledMat = new THREE.MeshBasicMaterial({ color: arm.ledColor });
|
||||
const ledMesh = new THREE.Mesh(ledGeo, ledMat);
|
||||
ledMesh.position.set(0.12, -0.05, 0);
|
||||
motorGroup.add(ledMesh);
|
||||
this.strobeLeds.push(ledMesh);
|
||||
|
||||
// Rotating Rotor Assembly
|
||||
const propGroup = new THREE.Group();
|
||||
propGroup.position.y = 0.18;
|
||||
|
||||
// Rotor Hub Cap
|
||||
const hub = new THREE.Mesh(new THREE.ConeGeometry(0.08, 0.1, 12), metalMat);
|
||||
hub.position.y = 0.04;
|
||||
propGroup.add(hub);
|
||||
|
||||
// 3-Blade Propeller
|
||||
const bladeMat = new THREE.MeshStandardMaterial({
|
||||
color: 0x111111,
|
||||
roughness: 0.4,
|
||||
metalness: 0.5
|
||||
});
|
||||
|
||||
for (let b = 0; b < 3; b++) {
|
||||
const bladeAngle = (b * Math.PI * 2) / 3;
|
||||
const bladeGeo = new THREE.BoxGeometry(0.85, 0.015, 0.12);
|
||||
const bladeMesh = new THREE.Mesh(bladeGeo, bladeMat);
|
||||
bladeMesh.position.set(Math.cos(bladeAngle) * 0.42, 0, Math.sin(bladeAngle) * 0.42);
|
||||
bladeMesh.rotation.y = -bladeAngle;
|
||||
bladeMesh.rotation.x = 0.18; // Pitch twist
|
||||
bladeMesh.castShadow = true;
|
||||
propGroup.add(bladeMesh);
|
||||
}
|
||||
|
||||
// Motion blur rotor disc (semi-transparent glowing disc when spinning fast)
|
||||
const discGeo = new THREE.RingGeometry(0.1, 0.95, 24);
|
||||
discGeo.rotateX(Math.PI / 2);
|
||||
const discMat = new THREE.MeshBasicMaterial({
|
||||
color: this.primaryColor,
|
||||
transparent: true,
|
||||
opacity: 0.22,
|
||||
side: THREE.DoubleSide
|
||||
});
|
||||
const discMesh = new THREE.Mesh(discGeo, discMat);
|
||||
discMesh.position.y = 0.02;
|
||||
propGroup.add(discMesh);
|
||||
|
||||
motorGroup.add(propGroup);
|
||||
armGroup.add(motorGroup);
|
||||
this.group.add(armGroup);
|
||||
|
||||
this.rotors.push({ group: propGroup, dir: arm.dir });
|
||||
this.rotorDiscs.push(discMesh);
|
||||
});
|
||||
|
||||
// 4. Dual Carbon Landing Skids
|
||||
for (const side of [-1, 1]) {
|
||||
const skidGroup = new THREE.Group();
|
||||
skidGroup.position.set(side * 0.55, -0.22, 0);
|
||||
|
||||
// Struts
|
||||
const strut1 = new THREE.Mesh(new THREE.CylinderGeometry(0.03, 0.03, 0.4, 8), metalMat);
|
||||
strut1.position.set(0, -0.15, 0.4);
|
||||
strut1.rotation.z = side * 0.25;
|
||||
skidGroup.add(strut1);
|
||||
|
||||
const strut2 = new THREE.Mesh(new THREE.CylinderGeometry(0.03, 0.03, 0.4, 8), metalMat);
|
||||
strut2.position.set(0, -0.15, -0.4);
|
||||
strut2.rotation.z = side * 0.25;
|
||||
skidGroup.add(strut2);
|
||||
|
||||
// Longitudinal Runner
|
||||
const runnerGeo = new THREE.CylinderGeometry(0.04, 0.04, 1.4, 8);
|
||||
runnerGeo.rotateX(Math.PI / 2);
|
||||
const runner = new THREE.Mesh(runnerGeo, carbonMat);
|
||||
runner.position.set(side * 0.08, -0.35, 0);
|
||||
skidGroup.add(runner);
|
||||
|
||||
this.group.add(skidGroup);
|
||||
}
|
||||
|
||||
// 5. Underbelly Beacon Light
|
||||
const beaconLight = new THREE.PointLight(this.primaryColor, 1.8, 12);
|
||||
beaconLight.position.set(0, -0.25, 0);
|
||||
this.group.add(beaconLight);
|
||||
this.beaconLight = beaconLight;
|
||||
|
||||
// 6. Altitude Guide Drop Line & Ground Target Disc
|
||||
this.altLineGeo = new THREE.BufferGeometry().setFromPoints([
|
||||
new THREE.Vector3(0, 0, 0),
|
||||
new THREE.Vector3(0, -10, 0)
|
||||
]);
|
||||
this.altLineMat = new THREE.LineDashedMaterial({
|
||||
color: this.primaryColor,
|
||||
dashSize: 0.4,
|
||||
gapSize: 0.2,
|
||||
opacity: 0.5,
|
||||
transparent: true
|
||||
});
|
||||
this.altLine = new THREE.Line(this.altLineGeo, this.altLineMat);
|
||||
this.altLine.computeLineDistances();
|
||||
this.scene.add(this.altLine);
|
||||
|
||||
// Ground shadow/projection ring
|
||||
const groundRingGeo = new THREE.RingGeometry(0.3, 1.2, 24);
|
||||
groundRingGeo.rotateX(-Math.PI / 2);
|
||||
const groundRingMat = new THREE.MeshBasicMaterial({
|
||||
color: this.primaryColor,
|
||||
transparent: true,
|
||||
opacity: 0.35,
|
||||
side: THREE.DoubleSide
|
||||
});
|
||||
this.groundRing = new THREE.Mesh(groundRingGeo, groundRingMat);
|
||||
this.scene.add(this.groundRing);
|
||||
}
|
||||
|
||||
update(state, dt) {
|
||||
if (!state) return;
|
||||
|
||||
// Update Position & Attitude smoothly
|
||||
this.group.position.set(state.position.x, state.position.y, state.position.z);
|
||||
|
||||
// Pitch, Yaw, Roll rotations (Order: YXZ)
|
||||
const pitchRad = THREE.MathUtils.degToRad(state.rotation.pitch);
|
||||
const rollRad = THREE.MathUtils.degToRad(state.rotation.roll);
|
||||
const yawRad = state.rotation.yaw !== undefined ? THREE.MathUtils.degToRad(state.rotation.yaw) : 0;
|
||||
|
||||
this.group.rotation.set(0, 0, 0);
|
||||
this.group.rotation.order = "YXZ";
|
||||
this.group.rotation.y = yawRad;
|
||||
this.group.rotation.x = pitchRad;
|
||||
this.group.rotation.z = rollRad;
|
||||
|
||||
// Spin Propellers at RPM rate
|
||||
const speed = state.speed || 0;
|
||||
const baseSpin = (dt * 55.0) + (speed * 0.05);
|
||||
this.rotors.forEach((rotor, idx) => {
|
||||
rotor.group.rotation.y += baseSpin * rotor.dir;
|
||||
});
|
||||
|
||||
// Altitude Drop Line update
|
||||
const posY = this.group.position.y;
|
||||
const linePositions = new Float32Array([
|
||||
this.group.position.x, this.group.position.y, this.group.position.z,
|
||||
this.group.position.x, 0.02, this.group.position.z
|
||||
]);
|
||||
this.altLine.geometry.setAttribute('position', new THREE.BufferAttribute(linePositions, 3));
|
||||
this.altLine.computeLineDistances();
|
||||
|
||||
// Ground Shadow Ring update
|
||||
this.groundRing.position.set(this.group.position.x, 0.03, this.group.position.z);
|
||||
const ringScale = Math.max(0.6, 1.0 + (posY * 0.08));
|
||||
this.groundRing.scale.set(ringScale, ringScale, ringScale);
|
||||
|
||||
// Flashing Strobe LEDs
|
||||
const strobe = Math.sin(Date.now() * 0.008) > 0.4 ? 1.0 : 0.2;
|
||||
if (this.beaconLight) {
|
||||
this.beaconLight.intensity = 1.0 + strobe * 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.group && this.scene) this.scene.remove(this.group);
|
||||
if (this.altLine && this.scene) this.scene.remove(this.altLine);
|
||||
if (this.groundRing && this.scene) this.scene.remove(this.groundRing);
|
||||
}
|
||||
}
|
||||
|
||||
window.DroneModel = DroneModel;
|
||||
@@ -0,0 +1,598 @@
|
||||
/**
|
||||
* 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();
|
||||
});
|
||||
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* Tactical Radar HUD Canvas Component
|
||||
* Real-time 2D top-down aerial map showing 5 drones, trail histories, and sweep beam
|
||||
*/
|
||||
class TacticalRadar {
|
||||
constructor(canvasId) {
|
||||
this.canvas = document.getElementById(canvasId);
|
||||
if (!this.canvas) return;
|
||||
this.ctx = this.canvas.getContext('2d');
|
||||
this.width = this.canvas.width = 220;
|
||||
this.height = this.canvas.height = 220;
|
||||
this.cx = this.width / 2;
|
||||
this.cy = this.height / 2;
|
||||
this.range = 75.0; // World meters represented on radar radius
|
||||
|
||||
this.sweepAngle = 0.0;
|
||||
this.trails = {};
|
||||
this.maxTrail = 28;
|
||||
}
|
||||
|
||||
render(drones) {
|
||||
if (!this.ctx || !drones || !drones.length) return;
|
||||
const ctx = this.ctx;
|
||||
const w = this.width;
|
||||
const h = this.height;
|
||||
const cx = this.cx;
|
||||
const cy = this.cy;
|
||||
const r = cx - 10;
|
||||
|
||||
// Clear with dark HUD background
|
||||
ctx.fillStyle = "rgba(8, 14, 22, 0.45)";
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
// Circular clip for radar scope
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, r, 0, Math.PI * 2);
|
||||
ctx.clip();
|
||||
|
||||
// Background subtle grid rings
|
||||
ctx.strokeStyle = "rgba(0, 240, 255, 0.15)";
|
||||
ctx.lineWidth = 1;
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, (r / 3) * i, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Crosshairs
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cx - r, cy);
|
||||
ctx.lineTo(cx + r, cy);
|
||||
ctx.moveTo(cx, cy - r);
|
||||
ctx.lineTo(cx, cy + r);
|
||||
ctx.stroke();
|
||||
|
||||
// 45-degree guide dashes
|
||||
ctx.setLineDash([2, 4]);
|
||||
ctx.strokeStyle = "rgba(0, 240, 255, 0.08)";
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cx - r * 0.7, cy - r * 0.7);
|
||||
ctx.lineTo(cx + r * 0.7, cy + r * 0.7);
|
||||
ctx.moveTo(cx - r * 0.7, cy + r * 0.7);
|
||||
ctx.lineTo(cx + r * 0.7, cy - r * 0.7);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
|
||||
// Radar Sweep Line & Beam Gradient
|
||||
this.sweepAngle += 0.035;
|
||||
if (this.sweepAngle > Math.PI * 2) this.sweepAngle -= Math.PI * 2;
|
||||
|
||||
const sweepGrad = ctx.createRadialGradient(cx, cy, 0, cx, cy, r);
|
||||
sweepGrad.addColorStop(0, "rgba(0, 240, 255, 0.0)");
|
||||
sweepGrad.addColorStop(1, "rgba(0, 240, 255, 0.18)");
|
||||
|
||||
ctx.fillStyle = sweepGrad;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cx, cy);
|
||||
ctx.arc(cx, cy, r, this.sweepAngle - 0.45, this.sweepAngle);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
|
||||
ctx.strokeStyle = "rgba(0, 240, 255, 0.8)";
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cx, cy);
|
||||
ctx.lineTo(cx + Math.cos(this.sweepAngle) * r, cy + Math.sin(this.sweepAngle) * r);
|
||||
ctx.stroke();
|
||||
|
||||
// Convert world pos (X, Z) to radar canvas coords
|
||||
const scale = r / this.range;
|
||||
const toRadar = (pos) => ({
|
||||
x: cx + (pos.x * scale),
|
||||
y: cy + (pos.z * scale)
|
||||
});
|
||||
|
||||
const leaderPos = toRadar(drones[0].position);
|
||||
|
||||
// Draw formation link lines from leader to all wingmen
|
||||
ctx.strokeStyle = "rgba(0, 240, 255, 0.25)";
|
||||
ctx.lineWidth = 1;
|
||||
ctx.setLineDash([2, 2]);
|
||||
for (let i = 1; i < drones.length; i++) {
|
||||
const fPos = toRadar(drones[i].position);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(leaderPos.x, leaderPos.y);
|
||||
ctx.lineTo(fPos.x, fPos.y);
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.setLineDash([]);
|
||||
|
||||
// Update & Render each drone
|
||||
drones.forEach((d, idx) => {
|
||||
const radPos = toRadar(d.position);
|
||||
if (!this.trails[d.id]) this.trails[d.id] = [];
|
||||
this.trails[d.id].push({ ...radPos });
|
||||
if (this.trails[d.id].length > this.maxTrail) this.trails[d.id].shift();
|
||||
|
||||
// Draw Trail
|
||||
this._drawTrail(this.trails[d.id], d.color);
|
||||
|
||||
// Draw Drone Blip
|
||||
const yawRad = THREE.MathUtils.degToRad(d.rotation.yaw || 0);
|
||||
const label = idx === 0 ? "L-01" : `W-0${idx + 1}`;
|
||||
this._drawDroneBlip(radPos.x, radPos.y, yawRad, d.color, label, idx === 0);
|
||||
});
|
||||
|
||||
ctx.restore();
|
||||
|
||||
// Outer Ring Border
|
||||
ctx.strokeStyle = "rgba(0, 240, 255, 0.6)";
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, r, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
|
||||
// Compass Cardinal labels
|
||||
ctx.fillStyle = "rgba(0, 240, 255, 0.75)";
|
||||
ctx.font = "9px 'Share Tech Mono', monospace";
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillText("N", cx, cy - r + 8);
|
||||
ctx.fillText("S", cx, cy + r - 8);
|
||||
ctx.fillText("W", cx - r + 8, cy);
|
||||
ctx.fillText("E", cx + r - 8, cy);
|
||||
}
|
||||
|
||||
_drawTrail(trail, hexColor) {
|
||||
if (trail.length < 2) return;
|
||||
const ctx = this.ctx;
|
||||
for (let i = 0; i < trail.length - 1; i++) {
|
||||
const alpha = (i / trail.length) * 0.45;
|
||||
ctx.strokeStyle = hexColor;
|
||||
ctx.globalAlpha = alpha;
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(trail[i].x, trail[i].y);
|
||||
ctx.lineTo(trail[i + 1].x, trail[i + 1].y);
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.globalAlpha = 1.0;
|
||||
}
|
||||
|
||||
_drawDroneBlip(x, y, yaw, color, label, isLeader) {
|
||||
const ctx = this.ctx;
|
||||
ctx.save();
|
||||
ctx.translate(x, y);
|
||||
|
||||
// Blip glow
|
||||
ctx.fillStyle = color;
|
||||
ctx.shadowColor = color;
|
||||
ctx.shadowBlur = isLeader ? 10 : 6;
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, isLeader ? 4.5 : 3.0, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// Heading arrow
|
||||
ctx.rotate(yaw);
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = isLeader ? 2 : 1.5;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, 0);
|
||||
ctx.lineTo(0, isLeader ? 10 : 7);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.restore();
|
||||
|
||||
// Text label
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.font = "8px 'Share Tech Mono', monospace";
|
||||
ctx.textAlign = "left";
|
||||
ctx.fillText(label, x + 5, y - 3);
|
||||
}
|
||||
}
|
||||
|
||||
window.TacticalRadar = TacticalRadar;
|
||||
Reference in new issue
Block a user