최초 커밋

This commit is contained in:
KIM SU BO committed 2026-10-05 19:47:55 +09:00
1 parent a28d331e3e
commit 618ba02cb9
124 files changed
+49297 -2

No files matched your search

+48
View File
@@ -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` 키**: 착륙 패드 중앙으로 위치 초기화
+466
View File
@@ -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)
+3
View File
@@ -0,0 +1,3 @@
starlette>=0.30.0
uvicorn>=0.23.0
websockets>=11.0.0
+18
View File
@@ -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
+597
View File
@@ -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; }
}
+310
View File
@@ -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>
+141
View File
@@ -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();
+319
View File
@@ -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;
+598
View File
@@ -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();
});
+196
View File
@@ -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;