cute_drones
Ausführen ZIP (lokal ausführbar)Embed
<iframe src="https://code.turboflip.de/run/a867ff3c74ca2e7d/" sandbox="allow-scripts allow-forms allow-modals" loading="lazy" style="border:0;width:100%;height:480px"></iframe>
index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Drone Choreography Simulator</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div id="app">
<canvas id="canvas"></canvas>
<div id="hud">
<div id="hud-status">
<span id="hud-mode" class="chip">MANUAL</span>
<span id="hud-rec" class="chip hidden">● REC</span>
<span id="hud-input" class="chip">keyboard</span>
</div>
<div id="hud-stats">
<span id="hud-fps">-- fps</span>
<span id="hud-drones">1 drone</span>
</div>
</div>
<button id="panelToggle" title="Toggle panel (Esc)">☰</button>
<aside id="panel">
<div id="panelHeader">
<div>
<h1>Drone Sim</h1>
<p class="subtitle">Quadcopter choreography prototype</p>
</div>
<button id="panelClose" title="Close">✕</button>
</div>
<div id="panelContent"></div>
</aside>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script src="js/noise.js"></script>
<script src="js/config.js"></script>
<script src="js/physics.js"></script>
<script src="js/flags.js"></script>
<script src="js/input.js"></script>
<script src="js/choreography.js"></script>
<script src="js/bots.js"></script>
<script src="js/ui.js"></script>
<script src="js/main.js"></script>
</body>
</html>js/bots.js
/**
* bots.js — autonomous drones (bots) with offsets + swarm behaviour.
*
* Each bot has a config (offset, behaviour, follow strength, colour) that
* defines an *anchor target* (its role in the choreography). On top of that a
* Boids-style swarm layer (separation / cohesion / alignment + mosquito-like
* jitter) makes the drones avoid each other and move as a natural swarm.
* The result is converted into a desired up-vector + yaw + throttle and fed
* into the same Quadcopter.step used by the manual lead drone.
*
* Realism notes:
* - the desired tilt is limited to the drone's flight envelope (maxTilt)
* - the desired yaw is a *state* that turns at a limited rate (botYawRate),
* so bots can't snap around when their velocity direction flips
* - formation offsets follow the lead's *heading* only, not its tilt, so
* the lead's wobble is not amplified into the whole swarm
*/
(function () {
'use strict';
const AXIS_Y = new THREE.Vector3(0, 1, 0);
const _off = new THREE.Vector3();
const _tgt = new THREE.Vector3();
const _hA = new THREE.Vector3();
const _desUp = new THREE.Vector3();
const _mirror = new THREE.Vector3(-1, 1, -1);
const _q = new THREE.Quaternion();
const _qYaw = new THREE.Quaternion();
const clamp = THREE.MathUtils.clamp;
function wrapPI(a) {
a = (a + Math.PI) % (Math.PI * 2);
if (a < 0) a += Math.PI * 2;
return a - Math.PI;
}
/**
* Compute the anchor target (position + yaw) for a bot from its behaviour.
*/
function behaviorTarget(cfg, lead, bot, time, P) {
const off = cfg.offset;
let yaw = lead.yaw;
_qYaw.setFromAxisAngle(AXIS_Y, lead.yaw); // heading only (no tilt)
switch (cfg.behavior) {
case 'follow':
_tgt.copy(lead.pos).add(_off.set(off.x, off.y, off.z).applyQuaternion(_qYaw));
break;
case 'hover':
_tgt.copy(bot.anchor);
break;
case 'orbit': {
const ang = time * THREE.MathUtils.degToRad(P.orbitSpeed);
const r = Math.max(0.4, Math.hypot(off.x, off.z));
_tgt.set(
lead.pos.x + Math.cos(ang) * r,
lead.pos.y + off.y,
lead.pos.z + Math.sin(ang) * r
);
const dx = -Math.sin(ang), dz = Math.cos(ang);
yaw = Math.atan2(-dz, dx);
break;
}
case 'bob':
_tgt.copy(lead.pos).add(_off.set(off.x, off.y, off.z).applyQuaternion(_qYaw));
_tgt.y += Math.sin(time * Math.PI * 2 * P.bobFreq) * P.bobAmp;
break;
case 'mirror':
_tgt.copy(lead.pos).multiply(_mirror).add(_off.set(off.x, off.y, off.z));
break;
default:
_tgt.copy(lead.pos).add(_off.set(off.x, off.y, off.z).applyQuaternion(_qYaw));
}
return { pos: _tgt, yaw };
}
/**
* Turn the accumulated horizontal accel (_hA) + vertical accel into
* { throttle, up }. Limits accel and tilt to the flight envelope.
*/
function buildControl(drone, vA, P) {
const g = P.gravity;
vA = clamp(vA, -g * 0.85, g * 3);
let hMag = Math.hypot(_hA.x, _hA.z);
if (hMag > P.botMaxAccel) {
const s = P.botMaxAccel / hMag;
_hA.x *= s; _hA.z *= s; hMag = P.botMaxAccel;
}
// never ask for more tilt than the envelope allows (stay a bit inside it)
const upAcc = g + vA;
const hLimit = upAcc * Math.tan(THREE.MathUtils.degToRad(P.maxTilt) * 0.9);
if (hMag > hLimit) {
const s = hLimit / hMag;
_hA.x *= s; _hA.z *= s;
}
_desUp.set(_hA.x, upAcc, _hA.z).normalize();
const thrust = (drone.params.mass * upAcc) / Math.max(0.15, _desUp.y);
const throttle = clamp(thrust / P.maxThrust, 0, 1);
return { throttle, up: _desUp.clone() };
}
/**
* Swarm autopilot -> control inputs (desired up + yaw hint + throttle).
* Combines target-seeking PD with Boids steering (separation, cohesion,
* alignment) and a smooth per-bot jitter (mosquito-like wandering).
*/
function swarmControl(bot, targetPos, targetYaw, fs, P, positions, velocities, selfIdx, time, botIdx) {
// ---- target seeking (PD on position) ----
_hA.set(targetPos.x - bot.pos.x, 0, targetPos.z - bot.pos.z).multiplyScalar(P.botKp * fs);
_hA.x -= bot.vel.x * P.botKd * fs;
_hA.z -= bot.vel.z * P.botKd * fs;
let vA = (targetPos.y - bot.pos.y) * P.botKpV - bot.vel.y * P.botKdV;
// ---- boids ----
const sepR = P.swarmSepRadius;
const cohR = P.swarmNeighborRadius, cohR2 = cohR * cohR;
let sepX = 0, sepY = 0, sepZ = 0;
let cx = 0, cy = 0, cz = 0, vx = 0, vy = 0, vz = 0, nN = 0;
for (let k = 0; k < positions.length; k++) {
if (k === selfIdx) continue;
const o = positions[k];
const dx = o.x - bot.pos.x, dy = o.y - bot.pos.y, dz = o.z - bot.pos.z;
const d2 = dx * dx + dy * dy + dz * dz;
if (d2 >= cohR2 || d2 < 1e-6) continue;
const d = Math.sqrt(d2);
if (d < sepR) {
// separation: push away, linear falloff to 0 at sepR
const w = (1 - d / sepR) * P.swarmSepWeight / d;
sepX -= dx * w; sepY -= dy * w; sepZ -= dz * w;
}
cx += o.x; cy += o.y; cz += o.z;
vx += velocities[k].x; vy += velocities[k].y; vz += velocities[k].z;
nN++;
}
if (nN > 0) {
cx /= nN; cy /= nN; cz /= nN;
vx /= nN; vy /= nN; vz /= nN;
_hA.x += (cx - bot.pos.x) * P.swarmCohWeight + (vx - bot.vel.x) * P.swarmAlignWeight;
_hA.z += (cz - bot.pos.z) * P.swarmCohWeight + (vz - bot.vel.z) * P.swarmAlignWeight;
vA += (cy - bot.pos.y) * P.swarmCohWeight + (vy - bot.vel.y) * P.swarmAlignWeight;
}
_hA.x += sepX; _hA.z += sepZ;
vA += sepY;
// ---- mosquito-like jitter (smooth, per-bot, calmer when crowded) ----
const crowd = nN > 0 ? clamp(nN / 8, 0, 1) : 0;
const jit = P.swarmJitter * (1 - 0.75 * crowd);
_hA.x += Math.sin(time * 1.6 + botIdx * 2.1) * Math.cos(time * 1.2 + botIdx * 1.9) * jit;
_hA.z += Math.sin(time * 1.4 + botIdx * 1.5) * Math.cos(time * 1.8 + botIdx * 2.6) * jit;
vA += Math.sin(time * 1.1 + botIdx * 3.1) * 0.5 * jit;
const ctrl = buildControl(bot, vA, P);
// face the direction of travel when moving (smooth blend), else the
// behaviour yaw. The caller rate-limits the resulting yaw.
const hv = Math.hypot(bot.vel.x, bot.vel.z);
const wv = clamp((hv - 0.25) / 0.35, 0, 1);
const velYaw = Math.atan2(-bot.vel.z, bot.vel.x);
ctrl.yaw = targetYaw + wrapPI(velYaw - targetYaw) * wv;
return ctrl;
}
/**
* Pure target-seeking autopilot (no boids/jitter) — used for the lead drone
* and its position/altitude hold. Returns desired up + yaw + throttle.
*/
function autopilotControl(bot, targetPos, targetYaw, fs, P) {
_hA.set(targetPos.x - bot.pos.x, 0, targetPos.z - bot.pos.z).multiplyScalar(P.botKp * fs);
_hA.x -= bot.vel.x * P.botKd * fs;
_hA.z -= bot.vel.z * P.botKd * fs;
const vA = (targetPos.y - bot.pos.y) * P.botKpV - bot.vel.y * P.botKdV;
const ctrl = buildControl(bot, vA, P);
ctrl.yaw = targetYaw;
return ctrl;
}
class BotManager {
constructor(scene, params) {
this.scene = scene;
this.params = params;
this.bots = []; // { config, drone }
this._positions = [];
this._velocities = [];
}
count() { return this.bots.length; }
addBot(config, leadPos, droneFactory) {
const mesh = droneFactory(config.color);
this.scene.add(mesh);
const drone = new Quadcopter(this.params, mesh);
drone.reset(leadPos.clone().add(_off.set(config.offset.x, config.offset.y, config.offset.z)), 0);
const hover = this.params.mass * this.params.gravity / this.params.maxThrust;
const bot = {
config, drone,
anchor: new THREE.Vector3(),
smoothUp: new THREE.Vector3(0, 1, 0),
smoothThrottle: hover,
yawCmd: 0, // rate-limited heading command
};
bot.anchor.copy(drone.pos);
this.bots.push(bot);
return bot;
}
removeBot(index) {
const bot = this.bots[index];
if (!bot) return;
this.scene.remove(bot.drone.mesh);
this.bots.splice(index, 1);
}
sync(configs, leadPos, droneFactory) {
while (this.bots.length < configs.length) {
const cfg = configs[this.bots.length];
this.addBot(cfg, leadPos, droneFactory);
}
while (this.bots.length > configs.length) {
this.removeBot(this.bots.length - 1);
}
for (let i = 0; i < configs.length; i++) {
const bot = this.bots[i];
const cfg = configs[i];
bot.config = cfg;
if (bot.drone.mesh.userData.color !== cfg.color) {
bot.drone.mesh.userData.setCamo(cfg.color);
bot.drone.mesh.userData.color = cfg.color;
}
}
}
resetAll(leadPos, leadYaw) {
const leadQ = _q.setFromAxisAngle(AXIS_Y, leadYaw);
const hover = this.params.mass * this.params.gravity / this.params.maxThrust;
for (const bot of this.bots) {
_off.set(bot.config.offset.x, bot.config.offset.y, bot.config.offset.z).applyQuaternion(leadQ);
bot.drone.reset(leadPos.clone().add(_off), leadYaw);
bot.anchor.copy(bot.drone.pos);
bot.yawCmd = leadYaw;
bot.smoothUp.set(0, 1, 0);
bot.smoothThrottle = hover;
}
}
setAnchors(leadPos) {
for (const bot of this.bots) {
_off.set(bot.config.offset.x, bot.config.offset.y, bot.config.offset.z);
bot.anchor.copy(leadPos).add(_off);
}
}
/**
* @param {object} lead { pos, quat, yaw, vel }
* @param {number} dt
* @param {number} time simulation time
* @param {object} P config values
* @param {WindField} wind
*/
update(lead, dt, time, P, wind) {
const n = this.bots.length;
// build neighbour arrays (lead is index 0)
const positions = this._positions;
const velocities = this._velocities;
positions.length = n + 1;
velocities.length = n + 1;
positions[0] = lead.pos; velocities[0] = lead.vel;
for (let i = 0; i < n; i++) {
positions[i + 1] = this.bots[i].drone.pos;
velocities[i + 1] = this.bots[i].drone.vel;
}
const maxTurn = THREE.MathUtils.degToRad(P.botYawRate);
const alpha = 1 - Math.exp(-P.botSmoothing * dt);
for (let i = 0; i < n; i++) {
const bot = this.bots[i];
if (bot.config.enabled === false) {
bot.drone.syncMesh();
continue;
}
const target = behaviorTarget(bot.config, lead, bot, time, P);
const ctrl = swarmControl(
bot.drone, target.pos, target.yaw, bot.config.followStrength,
P, positions, velocities, i + 1, time, i
);
// smooth the control signal (low-pass) to remove jitter
bot.smoothUp.lerp(ctrl.up, alpha).normalize();
bot.smoothThrottle += (ctrl.throttle - bot.smoothThrottle) * alpha;
ctrl.up.copy(bot.smoothUp);
ctrl.throttle = bot.smoothThrottle;
// turn towards the wanted heading at a limited rate (no snapping)
const dy = wrapPI(ctrl.yaw - bot.yawCmd);
bot.yawCmd += clamp(dy * 4, -maxTurn, maxTurn) * dt;
ctrl.yaw = bot.yawCmd;
ctrl.maxYawRate = P.botYawRate; // physical turn-rate limit too (no catch-up spin)
const w = wind.sample(bot.drone.pos, time, P);
bot.drone.step(dt, ctrl, w);
bot.drone.updateRotors(dt);
bot.drone.syncMesh();
}
}
syncMeshes() {
for (const bot of this.bots) bot.drone.syncMesh();
}
}
window.behaviorTarget = behaviorTarget;
window.swarmControl = swarmControl;
window.autopilotControl = autopilotControl;
window.BotManager = BotManager;
})();js/choreography.js
/**
* choreography.js — recording & playback of flight tracks.
*
* A Track is a list of timestamped poses (position + quaternion) sampled from
* the lead drone. Playback interpolates between samples, optionally looping.
* This is the foundation of the "choreography" idea: record your flight once,
* then replay it as the lead while bots follow with their offsets/behaviors.
*/
(function () {
'use strict';
class Recorder {
constructor() {
this.recording = false;
this.t = 0;
this.frames = [];
this.interval = 1 / 30; // sample every ~30ms
this._sinceSample = 0;
}
start() {
this.frames = [];
this.t = 0;
this.recording = true;
this._sinceSample = 0;
}
stop() {
this.recording = false;
return new Track(this.frames);
}
clear() {
this.frames = [];
this.t = 0;
this.recording = false;
}
/**
* @param {number} dt simulation dt
* @param {THREE.Vector3} pos
* @param {THREE.Quaternion} quat
*/
update(dt, pos, quat) {
if (!this.recording) return;
this.t += dt;
this._sinceSample += dt;
if (this._sinceSample >= this.interval) {
this._sinceSample -= this.interval;
this.frames.push({
t: this.t,
px: pos.x, py: pos.y, pz: pos.z,
qx: quat.x, qy: quat.y, qz: quat.z, qw: quat.w,
});
}
}
get duration() { return this.t; }
}
class Track {
constructor(frames) {
this.frames = frames || [];
this.duration = this.frames.length ? this.frames[this.frames.length - 1].t : 0;
}
get empty() { return this.frames.length === 0; }
/**
* Sample the track at time `t`.
* @param {number} t
* @param {boolean} loop
* @param {THREE.Vector3} outPos
* @param {THREE.Quaternion} outQuat
* @returns {boolean} whether a sample was produced
*/
sample(t, loop, outPos, outQuat) {
if (this.empty) return false;
if (loop && this.duration > 0) {
t = t % this.duration;
if (t < 0) t += this.duration;
} else if (t < 0) {
t = 0;
}
if (!loop && t >= this.duration) t = this.duration;
const frames = this.frames;
// clamp / find segment
if (t <= frames[0].t) {
outPos.set(frames[0].px, frames[0].py, frames[0].pz);
outQuat.set(frames[0].qx, frames[0].qy, frames[0].qz, frames[0].qw);
return true;
}
const last = frames[frames.length - 1];
if (t >= last.t) {
outPos.set(last.px, last.py, last.pz);
outQuat.set(last.qx, last.qy, last.qz, last.qw);
return true;
}
// binary search
let lo = 0, hi = frames.length - 1;
while (hi - lo > 1) {
const mid = (lo + hi) >> 1;
if (frames[mid].t <= t) lo = mid; else hi = mid;
}
const a = frames[lo], b = frames[hi];
const f = (t - a.t) / Math.max(1e-6, b.t - a.t);
outPos.set(
a.px + (b.px - a.px) * f,
a.py + (b.py - a.py) * f,
a.pz + (b.pz - a.pz) * f
);
outQuat.set(a.qx, a.qy, a.qz, a.qw).slerp(
_q.set(b.qx, b.qy, b.qz, b.qw), f
);
return true;
}
toJSON() {
return {
interval: 1 / 30,
frames: this.frames,
};
}
static fromJSON(data) {
if (!data || !data.frames) return new Track([]);
return new Track(data.frames);
}
}
const _q = new THREE.Quaternion();
const _pos = new THREE.Vector3();
window.Recorder = Recorder;
window.Track = Track;
})();
js/config.js
/**
* config.js — central parameter schema + live Config store + bot defaults.
* The UI is generated from PARAM_DEFS and BEHAVIORS. Save/load serializes
* Config.values and Config.bots.
*/
(function () {
'use strict';
const PARAM_DEFS = {
// ---- Physics ----
gravity: { label: 'Gravity', group: 'physics', min: 0, max: 25, step: 0.1, value: 9.81, unit: 'm/s²' },
mass: { label: 'Mass', group: 'physics', min: 0.1, max: 3, step: 0.01, value: 0.65, unit: 'kg' },
maxThrust: { label: 'Max thrust', group: 'physics', min: 5, max: 100, step: 0.5, value: 26, unit: 'N' },
maxTilt: { label: 'Max tilt', group: 'physics', min: 5, max: 65, step: 1, value: 38, unit: '°' },
yawRate: { label: 'Max yaw rate', group: 'physics', min: 30, max: 720, step: 5, value: 220, unit: '°/s' },
maxRate: { label: 'Max tilt rate', group: 'physics', min: 60, max: 720, step: 5, value: 220, unit: '°/s' },
drag: { label: 'Linear drag', group: 'physics', min: 0, max: 4, step: 0.01, value: 0.6, unit: '' },
dragQuad: { label: 'Quadratic drag', group: 'physics', min: 0, max: 1, step: 0.005, value: 0.04, unit: '' },
Kp: { label: 'Attitude P', group: 'physics', min: 1, max: 30, step: 0.5, value: 7, unit: '1/s' },
Kd: { label: 'Rate gain', group: 'physics', min: 2, max: 60, step: 0.5, value: 18, unit: '1/s' },
maxTorque: { label: 'Max tilt torque', group: 'physics', min: 0.05, max: 5, step: 0.05, value: 0.6, unit: 'N·m' },
maxYawTorque: { label: 'Max yaw torque', group: 'physics', min: 0.01, max: 1, step: 0.01, value: 0.12, unit: 'N·m' },
motorLag: { label: 'Motor lag', group: 'physics', min: 0.005,max: 0.15, step: 0.005, value: 0.035, unit: 's' },
inertiaPitch: { label: 'Inertia (tilt)', group: 'physics', min: 0.002,max: 0.3, step: 0.001, value: 0.013, unit: 'kg·m²' },
inertiaYaw: { label: 'Inertia (yaw)', group: 'physics', min: 0.002,max: 0.5, step: 0.001, value: 0.022, unit: 'kg·m²' },
// ---- Wind & turbulence (calm indoor values) ----
windEnabled: { label: 'Enable wind', group: 'wind', type: 'bool', value: true },
windSpeed: { label: 'Wind speed', group: 'wind', min: 0, max: 18, step: 0.1, value: 0.4, unit: 'm/s' },
windHeading: { label: 'Wind heading', group: 'wind', min: 0, max: 360, step: 1, value: 40, unit: '°' },
windGust: { label: 'Gust strength', group: 'wind', min: 0, max: 12, step: 0.1, value: 0.12, unit: 'm/s' },
turbulence: { label: 'Turbulence force', group: 'wind', min: 0, max: 8, step: 0.01, value: 0.03, unit: 'N' },
turbTorque: { label: 'Turbulence torque',group: 'wind', min: 0, max: 5, step: 0.005, value: 0.015, unit: 'N·m' },
noiseScale: { label: 'Noise scale', group: 'wind', min: 0.002,max: 0.6, step: 0.002, value: 0.05, unit: '' },
timeScale: { label: 'Wind time scale', group: 'wind', min: 0.005,max: 1, step: 0.005, value: 0.08, unit: '' },
// ---- Camera (fixed) ----
fov: { label: 'Field of view', group: 'camera', min: 40, max: 120, step: 1, value: 75, unit: '°' },
camDistance: { label: 'Distance (zoom)', group: 'camera', min: 2, max: 30, step: 0.1, value: 7.3, unit: 'm' },
// ---- Autopilot (target seeking / position hold) ----
botKp: { label: 'Target P', group: 'autopilot', min: 0.2, max: 20, step: 0.1, value: 4, unit: '' },
botKd: { label: 'Target D', group: 'autopilot', min: 0, max: 15, step: 0.1, value: 3.5, unit: '' },
botKpV: { label: 'Vertical P', group: 'autopilot', min: 0.2, max: 25, step: 0.1, value: 5, unit: '' },
botKdV: { label: 'Vertical D', group: 'autopilot', min: 0, max: 15, step: 0.1, value: 4, unit: '' },
botMaxAccel: { label: 'Max accel', group: 'autopilot', min: 2, max: 60, step: 0.5, value: 24, unit: 'm/s²' },
botSmoothing: { label: 'Control smoothing',group: 'autopilot', min: 1, max: 40, step: 0.5, value: 12, unit: '' },
botYawRate: { label: 'Bot turn rate', group: 'autopilot', min: 10, max: 360,step: 5, value: 90, unit: '°/s' },
leadMaxSpeed: { label: 'Lead max speed', group: 'autopilot', min: 0.5, max: 10, step: 0.1, value: 3, unit: 'm/s' },
orbitSpeed: { label: 'Orbit speed', group: 'autopilot', min: 10, max: 360,step: 5, value: 90, unit: '°/s' },
bobFreq: { label: 'Bob frequency', group: 'autopilot', min: 0.1, max: 4, step: 0.05,value: 0.7, unit: 'Hz' },
bobAmp: { label: 'Bob amplitude', group: 'autopilot', min: 0.1, max: 3, step: 0.05,value: 0.5, unit: 'm' },
// ---- Swarm (boids) ----
swarmSepWeight: { label: 'Separation', group: 'swarm', min: 0, max: 10, step: 0.1, value: 1.8, unit: '' },
swarmCohWeight: { label: 'Cohesion', group: 'swarm', min: 0, max: 6, step: 0.05, value: 1.0, unit: '' },
swarmAlignWeight: { label: 'Alignment', group: 'swarm', min: 0, max: 6, step: 0.05, value: 1.2, unit: '' },
swarmNeighborRadius: { label: 'Neighbor radius', group: 'swarm', min: 0.2, max: 5, step: 0.05, value: 1.4, unit: 'm' },
swarmSepRadius: { label: 'Separation radius', group: 'swarm', min: 0.1, max: 3, step: 0.05, value: 0.45, unit: 'm' },
swarmJitter: { label: 'Jitter (mücken)', group: 'swarm', min: 0, max: 3, step: 0.05, value: 0.2, unit: '' },
// ---- Flags (cloth) ----
flagsEnabled: { label: 'Show flags', group: 'flags', type: 'bool', value: true },
flagLength: { label: 'Flag length', group: 'flags', min: 0.05, max: 1.5, step: 0.01, value: 0.35, unit: 'm' },
flagWidth: { label: 'Flag width', group: 'flags', min: 0.04, max: 0.3, step: 0.005, value: 0.12, unit: 'm' },
flagWeight: { label: 'Cloth weight', group: 'flags', min: 20, max: 600, step: 5, value: 120, unit: 'g/m²' },
flagDrag: { label: 'Air drag', group: 'flags', min: 0, max: 3, step: 0.05, value: 1, unit: '' },
flagDamping: { label: 'Cloth damping', group: 'flags', min: 0, max: 3, step: 0.05, value: 0.7, unit: '1/s' },
// ---- Scene / simulation ----
simSpeed: { label: 'Sim speed', group: 'scene', min: 0.1, max: 3, step: 0.05, value: 1, unit: '×' },
showGrid: { label: 'Ground grid', group: 'scene', type: 'bool', value: true },
showTrails: { label: 'Trajectory trails',group:'scene', type: 'bool', value: false },
// ---- Playback ----
playbackSpeed:{ label: 'Playback speed', group: 'playback', min: 0.1, max: 4, step: 0.05, value: 1, unit: '×' },
};
const GROUP_ORDER = ['physics', 'wind', 'camera', 'autopilot', 'swarm', 'flags', 'scene', 'playback'];
const GROUP_LABELS = {
physics: 'Physics',
wind: 'Wind & Turbulence',
camera: 'Camera',
autopilot: 'Autopilot',
swarm: 'Swarm (Boids)',
flags: 'Flags (Cloth)',
scene: 'Scene & Simulation',
playback: 'Playback',
};
const BEHAVIORS = [
{ id: 'follow', label: 'Follow (formation)' },
{ id: 'hover', label: 'Hover (anchor)' },
{ id: 'orbit', label: 'Orbit' },
{ id: 'bob', label: 'Bob' },
{ id: 'mirror', label: 'Mirror' },
];
// Pastel palette for the default swarm.
const PASTEL_PALETTE = [
'#f4a7b9', '#f7c59f', '#f9e3a0', '#a8d5a2', '#9fc5e8',
'#c3a6e0', '#f2a6c9', '#9fd8d0', '#f4b8a0', '#d4c1f0',
'#b8e0c9', '#a8d8e8',
];
// 47 bots + 1 lead = 48 drones total.
function defaultBots() {
const bots = [];
const N = 47;
for (let i = 0; i < N; i++) {
const ang = Math.random() * Math.PI * 2;
const r = 0.3 + Math.random() * 1.3;
const behavior = (i % 9 === 0) ? 'orbit' : (i % 13 === 0 ? 'bob' : 'follow');
bots.push({
name: 'Bot ' + (i + 1),
color: PASTEL_PALETTE[i % PASTEL_PALETTE.length],
behavior,
offset: {
x: Math.cos(ang) * r,
y: 0,
z: Math.sin(ang) * r,
},
followStrength: 0.7 + Math.random() * 0.6,
enabled: true,
});
}
return bots;
}
function buildValues() {
const v = {};
for (const key in PARAM_DEFS) v[key] = PARAM_DEFS[key].value;
return v;
}
const Config = {
values: buildValues(),
bots: defaultBots(),
};
function cloneBot(b) {
return {
name: b.name,
color: b.color,
behavior: b.behavior,
offset: { x: b.offset.x, y: b.offset.y, z: b.offset.z },
followStrength: b.followStrength,
enabled: b.enabled !== false,
};
}
function resetToDefaults() {
// mutate in place — drones hold a reference to Config.values
const defs = buildValues();
for (const key in Config.values) delete Config.values[key];
Object.assign(Config.values, defs);
Config.bots = defaultBots();
}
window.PARAM_DEFS = PARAM_DEFS;
window.GROUP_ORDER = GROUP_ORDER;
window.GROUP_LABELS = GROUP_LABELS;
window.BEHAVIORS = BEHAVIORS;
window.PASTEL_PALETTE = PASTEL_PALETTE;
window.BOT_COLORS = PASTEL_PALETTE; // ui.js "+ Add bot" uses this name
window.Config = Config;
window.cloneBot = cloneBot;
window.resetToDefaults = resetToDefaults;
})();js/flags.js
/**
* flags.js — small cloth flags hanging from every drone.
*
* Each flag is a 3 × 10 particle grid (slightly tapered towards the tip),
* simulated with position-based dynamics in world space:
* - the top row is pinned to a mount under the drone body (follows its
* position AND attitude, interpolated across sub-steps)
* - gravity, damping, and per-triangle aerodynamic drag (normal force from
* the velocity relative to the wind) -> inertia, trailing, fluttering
* - structural + shear + bending distance constraints
* Length / width / weight / drag are live parameters; the rest lengths are
* recomputed on change, so the cloth simply relaxes into the new size.
* The mesh takes the colour of its drone.
*/
(function () {
'use strict';
const COLS = 3; // across
const ROWS = 10; // along the length
const N = COLS * ROWS;
const TAPER = 0.55; // tip width relative to top width
const MAX_SUBSTEP = 1 / 120;
const MAX_SUBSTEPS = 4;
const ITERATIONS = 6;
const RHO_CD = 0.5 * 1.2 * 1.1; // ½ · air density · drag coefficient
const FLOOR = 0.01;
const JUMP = 0.6; // anchor moved further than this in one frame = teleport
const MAX_ACC = 200; // m/s², safety clamp
// mount point in the drone body frame (world-scale metres, drone mesh is scaled 0.62)
const ATTACH = new THREE.Vector3(-0.012, -0.085, 0);
const DRONE_SCALE = 0.62;
const idx = (i, j) => j * COLS + i;
/* ---- static topology ---- */
const cA = [], cB = [], cK = [];
function addCon(a, b, k) { cA.push(a); cB.push(b); cK.push(k); }
for (let j = 0; j < ROWS; j++) {
for (let i = 0; i < COLS; i++) {
if (i < COLS - 1) addCon(idx(i, j), idx(i + 1, j), 1); // horizontal
if (j < ROWS - 1) addCon(idx(i, j), idx(i, j + 1), 1); // vertical
if (i < COLS - 1 && j < ROWS - 1) { // shear
addCon(idx(i, j), idx(i + 1, j + 1), 0.8);
addCon(idx(i + 1, j), idx(i, j + 1), 0.8);
}
if (j < ROWS - 2) addCon(idx(i, j), idx(i, j + 2), 0.25); // bend (vertical)
if (i < COLS - 2) addCon(idx(i, j), idx(i + 2, j), 0.25); // bend (horizontal)
}
}
const NC = cA.length;
const CA = new Int32Array(cA), CB = new Int32Array(cB), CK = new Float32Array(cK);
const tris = [];
for (let j = 0; j < ROWS - 1; j++) {
for (let i = 0; i < COLS - 1; i++) {
const a = idx(i, j), b = idx(i + 1, j), c = idx(i, j + 1), d = idx(i + 1, j + 1);
tris.push(a, c, b, b, c, d);
}
}
const TRIS = new Uint16Array(tris);
const _v = new THREE.Vector3();
class Flag {
constructor(scene, color) {
this.scene = scene;
this.pos = new Float32Array(N * 3);
this.vel = new Float32Array(N * 3);
this.pred = new Float32Array(N * 3);
this.acc = new Float32Array(N * 3);
this.lz = new Float32Array(N); // rest layout: lateral offset
this.ly = new Float32Array(N); // rest layout: distance down
this.rest = new Float32Array(NC);
this.anchorPrev = new Float32Array(COLS * 3);
this.anchorCur = new Float32Array(COLS * 3);
this.length = -1;
this.width = -1;
this.area = 0.01;
this.color = null;
this.stale = true;
this.wind = new THREE.Vector3();
const geo = new THREE.BufferGeometry();
this.posAttr = new THREE.BufferAttribute(this.pos, 3);
this.posAttr.setUsage(THREE.DynamicDrawUsage);
geo.setAttribute('position', this.posAttr);
geo.setIndex(new THREE.BufferAttribute(TRIS, 1));
this.mesh = new THREE.Mesh(geo, new THREE.MeshStandardMaterial({
color: new THREE.Color(color), side: THREE.DoubleSide, roughness: 0.85, metalness: 0,
}));
this.mesh.frustumCulled = false;
scene.add(this.mesh);
this.setColor(color);
}
setColor(hex) {
if (this.color === hex) return;
this.color = hex;
this.mesh.material.color.set(hex);
}
setDimensions(length, width) {
this.length = length;
this.width = width;
for (let j = 0; j < ROWS; j++) {
const t = j / (ROWS - 1);
const w = width * (1 - (1 - TAPER) * t);
for (let i = 0; i < COLS; i++) {
const k = idx(i, j);
this.lz[k] = (i / (COLS - 1) - 0.5) * w;
this.ly[k] = -t * length;
}
}
for (let c = 0; c < NC; c++) {
const a = CA[c], b = CB[c];
this.rest[c] = Math.hypot(this.lz[a] - this.lz[b], this.ly[a] - this.ly[b]);
}
this.area = length * width * (1 - (1 - TAPER) * 0.5);
}
_computeAnchors(drone, out) {
for (let i = 0; i < COLS; i++) {
_v.set(ATTACH.x, ATTACH.y, this.lz[idx(i, 0)]).applyQuaternion(drone.quat).add(drone.pos);
out[i * 3] = _v.x; out[i * 3 + 1] = _v.y; out[i * 3 + 2] = _v.z;
}
}
/** Hang the cloth straight down from the current mount. */
reinit(drone) {
for (let j = 0; j < ROWS; j++) {
for (let i = 0; i < COLS; i++) {
const k = idx(i, j), o = k * 3;
_v.set(ATTACH.x, ATTACH.y, this.lz[k]).applyQuaternion(drone.quat).add(drone.pos);
_v.y = Math.max(FLOOR, _v.y + this.ly[k]);
this.pos[o] = _v.x; this.pos[o + 1] = _v.y; this.pos[o + 2] = _v.z;
this.vel[o] = drone.vel.x; this.vel[o + 1] = drone.vel.y; this.vel[o + 2] = drone.vel.z;
}
}
this._computeAnchors(drone, this.anchorPrev);
this.anchorCur.set(this.anchorPrev);
this.stale = false;
}
update(drone, dt, time, P, wind) {
if (P.flagLength !== this.length || P.flagWidth !== this.width) {
this.setDimensions(P.flagLength, P.flagWidth);
}
if (this.stale) this.reinit(drone);
this._computeAnchors(drone, this.anchorCur);
const dx = this.anchorCur[0] - this.anchorPrev[0];
const dy = this.anchorCur[1] - this.anchorPrev[1];
const dz = this.anchorCur[2] - this.anchorPrev[2];
if (dx * dx + dy * dy + dz * dz > JUMP * JUMP) this.reinit(drone); // teleport (reset / playback loop)
// air velocity at the drone (flags see the same wind, without the fast turbulence)
this.wind.copy(wind.sample(drone.pos, time, P).vel);
const pm = Math.max(1e-6, (P.flagWeight * 0.001 * this.area) / N); // kg per particle
const n = Math.min(MAX_SUBSTEPS, Math.max(1, Math.ceil(dt / MAX_SUBSTEP)));
const h = dt / n;
for (let s = 1; s <= n; s++) this._substep(h, s / n, P, pm);
this.anchorPrev.set(this.anchorCur);
// guard against numerical blow-up
const last = (N - 1) * 3 + 1;
if (!isFinite(this.pos[last]) || Math.abs(this.pos[last]) > 50) this.reinit(drone);
this.posAttr.needsUpdate = true;
this.mesh.geometry.computeVertexNormals();
}
_substep(h, f, P, pm) {
const pos = this.pos, vel = this.vel, pred = this.pred, acc = this.acc;
const g = P.gravity;
acc.fill(0);
// ---- aerodynamic drag per triangle (normal force from relative air speed) ----
const kAero = RHO_CD * P.flagDrag;
if (kAero > 0) {
const wx = this.wind.x, wy = this.wind.y, wz = this.wind.z;
for (let t = 0; t < TRIS.length; t += 3) {
const a = TRIS[t] * 3, b = TRIS[t + 1] * 3, c = TRIS[t + 2] * 3;
const e1x = pos[b] - pos[a], e1y = pos[b + 1] - pos[a + 1], e1z = pos[b + 2] - pos[a + 2];
const e2x = pos[c] - pos[a], e2y = pos[c + 1] - pos[a + 1], e2z = pos[c + 2] - pos[a + 2];
let nx = e1y * e2z - e1z * e2y;
let ny = e1z * e2x - e1x * e2z;
let nz = e1x * e2y - e1y * e2x;
const len = Math.sqrt(nx * nx + ny * ny + nz * nz);
if (len < 1e-9) continue;
nx /= len; ny /= len; nz /= len;
const area = 0.5 * len;
const rvx = (vel[a] + vel[b] + vel[c]) / 3 - wx;
const rvy = (vel[a + 1] + vel[b + 1] + vel[c + 1]) / 3 - wy;
const rvz = (vel[a + 2] + vel[b + 2] + vel[c + 2]) / 3 - wz;
const vn = rvx * nx + rvy * ny + rvz * nz;
const fa = (-kAero * area * Math.abs(vn) * vn) / (3 * pm);
acc[a] += nx * fa; acc[a + 1] += ny * fa; acc[a + 2] += nz * fa;
acc[b] += nx * fa; acc[b + 1] += ny * fa; acc[b + 2] += nz * fa;
acc[c] += nx * fa; acc[c + 1] += ny * fa; acc[c + 2] += nz * fa;
}
}
// ---- predict ----
const damp = Math.exp(-P.flagDamping * h);
for (let j = 0; j < ROWS; j++) {
for (let i = 0; i < COLS; i++) {
const o = idx(i, j) * 3;
if (j === 0) {
// pinned to the drone (mount interpolated across the frame)
const ao = i * 3;
pred[o] = this.anchorPrev[ao] + (this.anchorCur[ao] - this.anchorPrev[ao]) * f;
pred[o + 1] = this.anchorPrev[ao + 1] + (this.anchorCur[ao + 1] - this.anchorPrev[ao + 1]) * f;
pred[o + 2] = this.anchorPrev[ao + 2] + (this.anchorCur[ao + 2] - this.anchorPrev[ao + 2]) * f;
} else {
let ax = acc[o], ay = acc[o + 1] - g, az = acc[o + 2];
const am2 = ax * ax + ay * ay + az * az;
if (am2 > MAX_ACC * MAX_ACC) { const s = MAX_ACC / Math.sqrt(am2); ax *= s; ay *= s; az *= s; }
vel[o] = (vel[o] + ax * h) * damp;
vel[o + 1] = (vel[o + 1] + ay * h) * damp;
vel[o + 2] = (vel[o + 2] + az * h) * damp;
pred[o] = pos[o] + vel[o] * h;
pred[o + 1] = pos[o + 1] + vel[o + 1] * h;
pred[o + 2] = pos[o + 2] + vel[o + 2] * h;
}
}
}
// ---- constraints (row 0 = pinned, inverse mass 0) ----
const rest = this.rest;
for (let it = 0; it < ITERATIONS; it++) {
for (let c = 0; c < NC; c++) {
const a = CA[c], b = CB[c];
const wa = a < COLS ? 0 : 1, wb = b < COLS ? 0 : 1;
const ws = wa + wb;
if (ws === 0) continue;
const ao = a * 3, bo = b * 3;
const dx = pred[bo] - pred[ao];
const dy = pred[bo + 1] - pred[ao + 1];
const dz = pred[bo + 2] - pred[ao + 2];
const d = Math.sqrt(dx * dx + dy * dy + dz * dz);
if (d < 1e-9) continue;
const s = (CK[c] * (d - rest[c])) / (d * ws);
pred[ao] += dx * s * wa; pred[ao + 1] += dy * s * wa; pred[ao + 2] += dz * s * wa;
pred[bo] -= dx * s * wb; pred[bo + 1] -= dy * s * wb; pred[bo + 2] -= dz * s * wb;
}
}
// ---- floor (with friction) ----
for (let k = COLS; k < N; k++) {
const o = k * 3;
if (pred[o + 1] < FLOOR) {
pred[o + 1] = FLOOR;
pred[o] = pos[o] + (pred[o] - pos[o]) * 0.6;
pred[o + 2] = pos[o + 2] + (pred[o + 2] - pos[o + 2]) * 0.6;
}
}
// ---- commit ----
const inv = 1 / h;
for (let o = 0; o < N * 3; o++) {
vel[o] = (pred[o] - pos[o]) * inv;
pos[o] = pred[o];
}
}
dispose() {
this.scene.remove(this.mesh);
this.mesh.geometry.dispose();
this.mesh.material.dispose();
}
}
class FlagSystem {
constructor(scene) {
this.scene = scene;
this.flags = [];
}
_ensure(count) {
while (this.flags.length < count) this.flags.push(new Flag(this.scene, '#cccccc'));
while (this.flags.length > count) this.flags.pop().dispose();
}
/**
* @param {Quadcopter} lead
* @param {string} leadColor
* @param {Array} bots BotManager.bots ({config, drone})
*/
update(lead, leadColor, bots, dt, time, P, wind) {
const count = 1 + bots.length;
this._ensure(count);
const on = P.flagsEnabled !== false;
for (let i = 0; i < count; i++) {
const drone = i === 0 ? lead : bots[i - 1].drone;
const color = i === 0 ? leadColor : bots[i - 1].config.color;
const flag = this.flags[i];
const rod = drone.mesh && drone.mesh.userData.flagRod;
if (rod) rod.visible = on;
if (!on) { flag.mesh.visible = false; flag.stale = true; continue; }
flag.mesh.visible = true;
flag.setColor(color);
flag.update(drone, dt, time, P, wind);
}
}
/** Re-hang all flags on the next update (after a reset / teleport). */
reset() {
for (const f of this.flags) f.stale = true;
}
}
window.FlagSystem = FlagSystem;
window.FLAG_ATTACH = ATTACH;
window.FLAG_DRONE_SCALE = DRONE_SCALE;
})();js/input.js
/**
* input.js — Xbox gamepad + keyboard fallback.
*
* Produces a *velocity-command* control (DJI-style position/altitude hold):
* fwd -1..1 forward/backward (neutral = hold position)
* strafe -1..1 left/right (neutral = hold position)
* lift -1..1 up/down (neutral = hold altitude)
* yaw -1..1 rotate
*
* Gamepad (Mode 2):
* left stick Y -> lift, X -> yaw
* right stick Y -> forward, X -> strafe
* Keyboard:
* W/S lift · A/D yaw · arrows forward/strafe · Space/Shift lift
* R reset · G record · P play · L loop · C clear · T trails
*/
(function () {
'use strict';
const DEADZONE = 0.12;
class InputManager {
constructor() {
this.gamepadIndex = null;
this.gamepadActive = false;
this.keys = {};
this.justPressed = new Set();
// smoothed keyboard velocity commands (return to 0 = hold)
this.kb = { fwd: 0, strafe: 0, lift: 0, yaw: 0 };
this._prevButtons = new Array(17).fill(false);
this._bind();
}
_bind() {
window.addEventListener('gamepadconnected', (e) => {
this.gamepadIndex = e.gamepad.index;
this.gamepadActive = true;
this._emitInputState();
});
window.addEventListener('gamepaddisconnected', (e) => {
if (this.gamepadIndex === e.gamepad.index) {
this.gamepadIndex = null;
this.gamepadActive = false;
this._emitInputState();
}
});
window.addEventListener('keydown', (e) => {
if (e.repeat) return;
this.keys[e.code] = true;
this._handleActionKey(e.code);
});
window.addEventListener('keyup', (e) => { this.keys[e.code] = false; });
window.addEventListener('blur', () => { this.keys = {}; });
}
_emitInputState() {
window.dispatchEvent(new CustomEvent('inputchange', { detail: this.gamepadActive ? 'gamepad' : 'keyboard' }));
}
_handleActionKey(code) {
const map = {
'KeyR': 'reset', 'KeyG': 'record', 'KeyP': 'play',
'KeyL': 'loop', 'KeyC': 'clear', 'KeyT': 'trails',
};
if (map[code]) this.justPressed.add(map[code]);
}
_applyDeadzone(v) {
if (Math.abs(v) < DEADZONE) return 0;
return (v - Math.sign(v) * DEADZONE) / (1 - DEADZONE);
}
poll(dt) {
const actions = new Set(this.justPressed);
this.justPressed.clear();
const pads = navigator.getGamepads ? navigator.getGamepads() : [];
let gp = null;
if (this.gamepadIndex != null) gp = pads[this.gamepadIndex];
if (!gp) {
for (let i = 0; i < pads.length; i++) {
if (pads[i] && pads[i].connected) { gp = pads[i]; this.gamepadIndex = i; break; }
}
}
let ctrl;
if (gp) {
if (!this.gamepadActive) { this.gamepadActive = true; this._emitInputState(); }
ctrl = this._gamepadCtrl(gp);
this._gamepadActions(gp, actions);
} else {
if (this.gamepadActive) { this.gamepadActive = false; this._emitInputState(); }
ctrl = this._keyboardCtrl(dt);
}
return { ctrl, actions };
}
_gamepadCtrl(gp) {
const ax = gp.axes;
const lx = this._applyDeadzone(ax[0] || 0); // left X -> yaw
const ly = this._applyDeadzone(ax[1] || 0); // left Y -> lift (up = -1)
const rx = this._applyDeadzone(ax[2] || 0); // right X -> strafe
const ry = this._applyDeadzone(ax[3] || 0); // right Y -> forward (up = -1)
return {
fwd: -ry,
strafe: rx,
lift: -ly,
yaw: -lx,
};
}
_keyboardCtrl(dt) {
const k = this.keys;
const kb = this.kb;
const ramp = 7 * dt;
const target = (neg, pos) => ((neg ? -1 : 0) + (pos ? 1 : 0));
const rampTo = (cur, t) => {
const diff = t - cur;
if (Math.abs(diff) < 1e-6) return t;
return cur + THREE.MathUtils.clamp(diff, -ramp, ramp);
};
kb.fwd = rampTo(kb.fwd, target(k['ArrowDown'], k['ArrowUp']));
kb.strafe = rampTo(kb.strafe, target(k['ArrowLeft'], k['ArrowRight']));
kb.yaw = rampTo(kb.yaw, target(k['KeyA'], k['KeyD']));
const liftTarget = THREE.MathUtils.clamp(
(k['KeyW'] ? 1 : 0) - (k['KeyS'] ? 1 : 0) + (k['Space'] ? 1 : 0) - (k['ShiftLeft'] || k['ShiftRight'] ? 1 : 0),
-1, 1);
kb.lift = rampTo(kb.lift, liftTarget);
return { fwd: kb.fwd, strafe: kb.strafe, lift: kb.lift, yaw: kb.yaw };
}
_gamepadActions(gp, actions) {
const btn = gp.buttons;
const map = { 0: 'reset', 3: 'record', 2: 'play', 1: 'loop', 8: 'clear', 9: 'trails' };
for (const idx in map) {
const pressed = btn[idx] && btn[idx].pressed;
if (pressed && !this._prevButtons[idx]) actions.add(map[idx]);
this._prevButtons[idx] = pressed;
}
}
activeLabel() { return this.gamepadActive ? 'gamepad' : 'keyboard'; }
}
window.InputManager = InputManager;
})();
js/main.js
/**
* main.js — scene setup, render loop, and wiring of everything together:
* manual lead drone + autonomous bots + wind + recording/playback choreography
* + cloth flags.
*/
(function () {
'use strict';
/* ------------------------------------------------------------------ *
* Renderer / scene / camera / lights
* ------------------------------------------------------------------ */
const canvas = document.getElementById('canvas');
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.outputEncoding = THREE.sRGBEncoding;
const scene = new THREE.Scene();
scene.background = new THREE.Color(0xffffff);
const camera = new THREE.PerspectiveCamera(
Config.values.fov, window.innerWidth / window.innerHeight, 0.1, 200);
// fixed, axis-aligned view: forward (+X) into the screen, +Z to the right.
// The camera sits along a fixed direction from a target and zooms by distance.
const camTarget = new THREE.Vector3(0, 1.2, 0);
const camDir = new THREE.Vector3(-6.5, 3.0, 1.3).normalize();
function applyCamera() {
camera.position.copy(camTarget).addScaledVector(camDir, Config.values.camDistance);
camera.lookAt(camTarget);
camera.position.set(0, 2, 4); // frontal von oben
camera.up.set(0, 0, -1); // wichtig! sonst kippt die Ansicht
camera.lookAt(0, 0, 0);
}
applyCamera();
// zoom with the mouse wheel
canvas.addEventListener('wheel', (e) => {
e.preventDefault();
const d = THREE.MathUtils.clamp(
Config.values.camDistance * Math.exp(e.deltaY * 0.0012), 2, 30);
Config.values.camDistance = d;
applyCamera();
UI.setControl('camDistance', d);
}, { passive: false });
scene.add(new THREE.HemisphereLight(0xffffff, 0xe5e7eb, 0.9));
const sun = new THREE.DirectionalLight(0xffffff, 0.55);
sun.position.set(12, 28, 10);
scene.add(sun);
// ground (room floor)
const ground = new THREE.Mesh(
new THREE.PlaneGeometry(5, 5),
new THREE.MeshStandardMaterial({ color: 0xf5f5f6, roughness: 1, metalness: 0 })
);
ground.rotation.x = -Math.PI / 2;
scene.add(ground);
const grid = new THREE.GridHelper(5, 10, 0xd8d8dd, 0xececef);
grid.position.y = 0.01;
grid.visible = Config.values.showGrid;
scene.add(grid);
// subtle room outline (5 × 5 × 3 m)
const roomEdges = new THREE.LineSegments(
new THREE.EdgesGeometry(new THREE.BoxGeometry(5, 3, 5)),
new THREE.LineBasicMaterial({ color: 0x9ca3af, transparent: true, opacity: 0.28 })
);
roomEdges.position.set(0, 1.5, 0);
scene.add(roomEdges);
/* ------------------------------------------------------------------ *
* Drone mesh factory — smooth low-poly 4-arm body, pastel camo + eyes
* ------------------------------------------------------------------ */
const DRONE_SCALE = 0.62;
const LEAD_COLOR = '#334155';
const FORWARD_COLOR = 0xff2d55; // vivid, opaque heading arrow on the lead
function hashStr(s) {
let h = 2166136261;
for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); }
return h >>> 0;
}
function mulberry32(a) {
return function () {
a |= 0; a = (a + 0x6D2B79F5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function makeCamoTexture(hex, isLead) {
const size = 64;
const c = document.createElement('canvas');
c.width = c.height = size;
const ctx = c.getContext('2d');
const base = new THREE.Color(hex);
const hsl = {};
base.getHSL(hsl);
const rng = mulberry32(hashStr(hex));
// pastel variants for the swarm; darker/muted for the lead
const lMin = isLead ? 0.30 : 0.62;
const lMax = isLead ? 0.52 : 0.88;
const sMax = isLead ? 0.50 : 0.60;
const sMin = isLead ? 0.25 : 0.28;
// 4 variants of the base hue
const cols = [];
for (let i = 0; i < 4; i++) {
let h = hsl.h + (i - 1.5) * 0.09;
h = h - Math.floor(h);
const s = THREE.MathUtils.clamp(hsl.s * 0.9, sMin, sMax);
const l = THREE.MathUtils.clamp(hsl.l + (i - 1.5) * 0.11, lMin, lMax);
const col = new THREE.Color().setHSL(h, s, l);
cols.push(col);
}
// camo blobs
for (let i = 0; i < 6; i++) {
const col = cols[i % cols.length];
ctx.fillStyle = '#' + col.getHexString();
const bx = rng() * size;
const by = rng() * size;
const rx = size * (0.22 + rng() * 0.28);
const ry = rx * (0.65 + rng() * 0.45);
ctx.beginPath();
ctx.ellipse(bx, by, rx, ry, rng() * Math.PI, 0, Math.PI * 2);
ctx.fill();
}
const tex = new THREE.CanvasTexture(c);
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
tex.encoding = THREE.sRGBEncoding;
return tex;
}
function createDroneMesh(colorHex, isLead) {
const g = new THREE.Group();
const camoMaterials = [];
function makeCamoMat(hex) {
const mat = new THREE.MeshStandardMaterial({ map: makeCamoTexture(hex, isLead), roughness: 0.72, metalness: 0.04 });
camoMaterials.push(mat);
return mat;
}
// lead: solid black so it stands out; bots: pastel camo
const bodyMat = isLead
? new THREE.MeshStandardMaterial({ color: 0x111111, roughness: 0.45, metalness: 0.3 })
: makeCamoMat(colorHex);
const propMat = new THREE.MeshStandardMaterial({
color: 0xe8e8ec, roughness: 0.35, metalness: 0.15, transparent: true, opacity: 0.55,
});
const eyeMat = new THREE.MeshBasicMaterial({ color: 0x111111 });
// central pod (smooth, flattened sphere)
const pod = new THREE.Mesh(new THREE.SphereGeometry(0.11, 10, 8), bodyMat);
pod.scale.set(1, 0.62, 1);
pod.userData.isBody = true;
g.add(pod);
// four tapered arms (X-configuration)
const armGeo = new THREE.CylinderGeometry(0.048, 0.02, 0.16, 5);
const dirs = [[1, 1], [1, -1], [-1, 1], [-1, -1]];
const props = [];
dirs.forEach(([dx, dz]) => {
const ang = Math.atan2(dz, dx);
const armGroup = new THREE.Group();
armGroup.rotation.y = -ang;
const arm = new THREE.Mesh(armGeo, bodyMat);
arm.rotation.z = -Math.PI / 2; // cylinder Y-axis -> +X
arm.position.x = 0.075;
arm.userData.isBody = true;
armGroup.add(arm);
// prop group at arm tip
const prop = new THREE.Group();
prop.position.set(0.15, 0.035, 0);
const disc = new THREE.Mesh(new THREE.CylinderGeometry(0.085, 0.085, 0.005, 12), propMat);
const blade = new THREE.Mesh(new THREE.BoxGeometry(0.16, 0.004, 0.034), propMat);
prop.add(disc, blade);
armGroup.add(prop);
props.push(prop);
g.add(armGroup);
});
g.props = props;
// two cute black button eyes on the front (+X)
const eyeGeo = new THREE.SphereGeometry(0.027, 8, 6);
const eye1 = new THREE.Mesh(eyeGeo, eyeMat);
eye1.position.set(0.085, 0.035, 0.052);
const eye2 = new THREE.Mesh(eyeGeo, eyeMat);
eye2.position.set(0.085, 0.035, -0.052);
g.add(eye1, eye2);
// white catchlight glints so the eyes read on the black lead
if (isLead) {
const glintGeo = new THREE.SphereGeometry(0.009, 4, 4);
const glintMat = new THREE.MeshBasicMaterial({ color: 0xffffff });
const gl1 = new THREE.Mesh(glintGeo, glintMat);
gl1.position.set(0.095, 0.045, 0.058);
const gl2 = new THREE.Mesh(glintGeo, glintMat);
gl2.position.set(0.095, 0.045, -0.058);
g.add(gl1, gl2);
}
// flag mount: thin rod from the pod down to where the flag's top edge is pinned
// (FLAG_ATTACH is in world metres; the drone group is scaled, so convert)
{
const ay = FLAG_ATTACH.y / DRONE_SCALE; // negative = below the body
const ax = FLAG_ATTACH.x / DRONE_SCALE;
const top = -0.04;
const rodLen = top - ay;
const rod = new THREE.Mesh(
new THREE.CylinderGeometry(0.007, 0.007, rodLen, 6),
new THREE.MeshStandardMaterial({ color: 0xb8bcc4, roughness: 0.6, metalness: 0.1 })
);
rod.position.set(ax, (top + ay) / 2, 0);
g.add(rod);
g.userData.flagRod = rod;
}
g.userData.color = colorHex;
g.userData.setCamo = function (hex) {
for (const m of camoMaterials) {
m.map.dispose();
m.map = makeCamoTexture(hex, isLead);
m.needsUpdate = true;
}
};
// prominent forward-direction arrow on the lead: solid shaft + cone,
// opaque and saturated so attitude wobble is easy to read
if (isLead) {
const fwdMat = new THREE.MeshBasicMaterial({ color: FORWARD_COLOR });
const start = 0.14, shaftLen = 1.05;
const shaft = new THREE.Mesh(new THREE.CylinderGeometry(0.035, 0.035, shaftLen, 12), fwdMat);
shaft.rotation.z = -Math.PI / 2; // cylinder Y-axis -> +X
shaft.position.set(start + shaftLen / 2, 0, 0);
g.add(shaft);
const head = new THREE.Mesh(new THREE.ConeGeometry(0.1, 0.26, 16), fwdMat);
head.rotation.z = -Math.PI / 2;
head.position.set(start + shaftLen + 0.13, 0, 0);
g.add(head);
}
// keep them small — like a swarm
g.scale.setScalar(DRONE_SCALE);
return g;
}
/* ------------------------------------------------------------------ *
* Drones
* ------------------------------------------------------------------ */
const leadMesh = createDroneMesh(LEAD_COLOR, true);
scene.add(leadMesh);
const lead = new Quadcopter(Config.values, leadMesh);
lead.reset(new THREE.Vector3(0, 0.12, 0), 0);
const leadHold = new THREE.Vector3(0, 0.12, 0); // position-hold target for the lead
const bots = new BotManager(scene, Config.values);
bots.sync(Config.bots, lead.pos, createDroneMesh);
// cloth flags (one per drone, lead included)
const flags = new FlagSystem(scene);
/* ------------------------------------------------------------------ *
* Wind / input / recording / playback
* ------------------------------------------------------------------ */
const wind = new WindField();
const input = new InputManager();
const recorder = new Recorder();
let currentTrack = new Track([]);
let playing = false;
let loop = false;
let playTime = 0;
let simTime = 0;
const _sampPos = new THREE.Vector3();
const _sampQuat = new THREE.Quaternion();
// heading of the body +X axis, projected on the ground plane.
// (+X axis in world = (R00, R10, R20); yaw = atan2(-R20, R00))
function yawFromQuat(q) {
const nx = 1 - 2 * (q.y * q.y + q.z * q.z);
const nz = 2 * (q.x * q.z - q.w * q.y);
return Math.atan2(-nz, nx);
}
/* ------------------------------------------------------------------ *
* Trails (optional trajectory visualization)
* ------------------------------------------------------------------ */
class Trail {
constructor(scene, color) {
this.pts = [];
this.MAX = 240;
this.geo = new THREE.BufferGeometry();
this.posAttr = new THREE.BufferAttribute(new Float32Array(this.MAX * 3), 3);
this.posAttr.setUsage(THREE.DynamicDrawUsage);
this.geo.setAttribute('position', this.posAttr);
this.geo.setDrawRange(0, 0);
this.line = new THREE.Line(this.geo, new THREE.LineBasicMaterial({
color: new THREE.Color(color), transparent: true, opacity: 0.3,
}));
this.line.visible = Config.values.showTrails;
scene.add(this.line);
}
push(p) {
this.pts.push(p.x, p.y, p.z);
if (this.pts.length > this.MAX * 3) this.pts.splice(0, this.pts.length - this.MAX * 3);
}
flush() {
const n = this.pts.length / 3;
for (let i = 0; i < this.pts.length; i++) this.posAttr.array[i] = this.pts[i];
this.posAttr.needsUpdate = true;
this.geo.setDrawRange(0, n);
}
clear() { this.pts.length = 0; this.geo.setDrawRange(0, 0); }
}
const leadTrail = new Trail(scene, '#1f2937');
const botTrails = bots.bots.map((b) => new Trail(scene, b.config.color));
function setTrailsVisible(v) {
leadTrail.line.visible = v;
botTrails.forEach((t) => { t.line.visible = v; });
}
function updateTrails() {
leadTrail.push(lead.pos);
leadTrail.flush();
bots.bots.forEach((b, i) => {
if (!botTrails[i]) return;
botTrails[i].push(b.drone.pos);
botTrails[i].flush();
});
}
/* ------------------------------------------------------------------ *
* Hooks (called by the UI)
* ------------------------------------------------------------------ */
function choreoInfo() {
return {
recording: recorder.recording,
playing,
loop,
hasTrack: !currentTrack.empty,
duration: currentTrack.duration,
frames: currentTrack.frames.length,
};
}
function updateHudMode() {
document.getElementById('hud-mode').textContent =
playing ? 'PLAYBACK' : (recorder.recording ? 'RECORDING' : 'MANUAL');
document.getElementById('hud-rec').classList.toggle('hidden', !recorder.recording);
}
function toggleRecord() {
if (recorder.recording) {
currentTrack = recorder.stop();
} else {
playing = false;
leadHold.copy(lead.pos);
recorder.start();
}
UI.setChoreo(choreoInfo());
updateHudMode();
}
function togglePlay() {
if (currentTrack.empty) return;
if (playing) {
playing = false;
leadHold.copy(lead.pos);
} else {
playing = true;
recorder.recording = false;
playTime = 0;
}
UI.setChoreo(choreoInfo());
updateHudMode();
}
function clearTrack() {
playing = false;
leadHold.copy(lead.pos);
recorder.clear();
currentTrack = new Track([]);
playTime = 0;
leadTrail.clear();
botTrails.forEach((t) => t.clear());
UI.setChoreo(choreoInfo());
updateHudMode();
}
function resetAll() {
playing = false;
lead.reset(new THREE.Vector3(0, 0.12, 0), 0);
lead.vel.set(0, 0, 0);
leadHold.copy(lead.pos);
bots.resetAll(lead.pos, lead.yaw);
flags.reset();
playTime = 0;
leadTrail.clear();
botTrails.forEach((t) => t.clear());
UI.setChoreo(choreoInfo());
updateHudMode();
}
function applyToScene() {
camera.fov = Config.values.fov;
camera.updateProjectionMatrix();
grid.visible = Config.values.showGrid;
setTrailsVisible(Config.values.showTrails);
applyCamera();
}
function serializeState() {
return {
version: 1,
savedAt: Date.now(),
values: Object.assign({}, Config.values),
bots: Config.bots.map(cloneBot),
track: currentTrack.toJSON(),
};
}
function applyState(state) {
if (!state) return;
if (state.values) Object.assign(Config.values, state.values); // mutate in place
if (state.bots) Config.bots = state.bots.map(cloneBot);
if (state.track) currentTrack = Track.fromJSON(state.track);
playing = false;
playTime = 0;
leadHold.copy(lead.pos);
bots.sync(Config.bots, lead.pos, createDroneMesh);
syncBotTrails();
applyToScene();
updateHudMode();
UI.setChoreo(choreoInfo());
UI.setControl('loop', loop);
}
const hooks = {
onParamChanged(key, value) {
if (key === 'fov') { camera.fov = value; camera.updateProjectionMatrix(); }
else if (key === 'camDistance') { applyCamera(); }
else if (key === 'showGrid') { grid.visible = value; }
else if (key === 'showTrails') { setTrailsVisible(value); }
},
onBotsChanged() {
bots.sync(Config.bots, lead.pos, createDroneMesh);
// keep trail colors/length in sync
syncBotTrails();
},
onRecord: toggleRecord,
onPlay: togglePlay,
onClear: clearTrack,
setLoop(v) { loop = v; },
onReset: resetAll,
onSetAnchors() { bots.setAnchors(lead.pos); },
onResetDefaults() {
resetToDefaults();
leadHold.copy(lead.pos);
bots.sync(Config.bots, lead.pos, createDroneMesh);
syncBotTrails();
applyToScene();
updateHudMode();
UI.setChoreo(choreoInfo());
},
serialize: serializeState,
applyState,
};
function syncBotTrails() {
// remove extra / add missing trail objects
while (botTrails.length > bots.bots.length) {
const t = botTrails.pop();
scene.remove(t.line);
}
while (botTrails.length < bots.bots.length) {
botTrails.push(new Trail(scene, bots.bots[botTrails.length].config.color));
}
bots.bots.forEach((b, i) => {
botTrails[i].line.material.color.set(b.config.color);
});
}
/* ------------------------------------------------------------------ *
* HUD
* ------------------------------------------------------------------ */
const fpsEl = document.getElementById('hud-fps');
const dronesEl = document.getElementById('hud-drones');
let frames = 0, fpsTime = 0, fps = 0;
function updateHud(dt) {
frames++;
fpsTime += dt;
if (fpsTime >= 0.5) {
fps = Math.round(frames / fpsTime);
frames = 0;
fpsTime = 0;
fpsEl.textContent = fps + ' fps';
}
dronesEl.textContent = (1 + bots.count()) + ' drones';
}
function handleActions(actions) {
if (actions.has('reset')) resetAll();
if (actions.has('record')) toggleRecord();
if (actions.has('play')) togglePlay();
if (actions.has('loop')) { loop = !loop; UI.setControl('loop', loop); UI.setChoreo(choreoInfo()); }
if (actions.has('clear')) clearTrack();
if (actions.has('trails')) {
Config.values.showTrails = !Config.values.showTrails;
setTrailsVisible(Config.values.showTrails);
UI.setControl('showTrails', Config.values.showTrails);
}
}
/* ------------------------------------------------------------------ *
* Main loop
* ------------------------------------------------------------------ */
function updateLeadManual(simDt, ctrl) {
const P = Config.values;
const maxSpeed = P.leadMaxSpeed;
const yaw = lead.yaw;
// body-frame velocity command -> world, integrated into the hold target
const fx = Math.cos(yaw), fz = -Math.sin(yaw);
const rx = Math.sin(yaw), rz = Math.cos(yaw);
leadHold.x += (fx * ctrl.fwd + rx * ctrl.strafe) * maxSpeed * simDt;
leadHold.z += (fz * ctrl.fwd + rz * ctrl.strafe) * maxSpeed * simDt;
leadHold.y += ctrl.lift * maxSpeed * simDt;
// keep the hold target inside the room
const b = ROOM_BOUNDS, m = 0.25;
leadHold.x = THREE.MathUtils.clamp(leadHold.x, b.min.x + m, b.max.x - m);
leadHold.z = THREE.MathUtils.clamp(leadHold.z, b.min.z + m, b.max.z - m);
leadHold.y = THREE.MathUtils.clamp(leadHold.y, b.min.y + 0.2, b.max.y - 0.25);
// yaw
lead.yaw += ctrl.yaw * THREE.MathUtils.degToRad(P.yawRate) * simDt;
const ac = autopilotControl(lead, leadHold, lead.yaw, 1, P);
const w = wind.sample(lead.pos, simTime, P);
lead.step(simDt, ac, w);
}
let last = performance.now();
function animate(now) {
requestAnimationFrame(animate);
let dt = (now - last) / 1000;
last = now;
if (dt > 0.05) dt = 0.05;
const simDt = dt * Config.values.simSpeed;
const { ctrl, actions } = input.poll(dt);
handleActions(actions);
UI.setInputLabel(input.activeLabel());
simTime += simDt;
// ---- lead drone ----
if (playing && !currentTrack.empty) {
playTime += simDt * Config.values.playbackSpeed;
const ok = currentTrack.sample(playTime, loop, _sampPos, _sampQuat);
if (ok) {
lead.pos.copy(_sampPos);
lead.quat.copy(_sampQuat);
lead.yaw = yawFromQuat(_sampQuat);
lead.vel.set(0, 0, 0);
lead.rotorSpeed = 150;
} else {
playing = false;
leadHold.copy(lead.pos);
UI.setChoreo(choreoInfo());
updateHudMode();
}
} else {
updateLeadManual(simDt, ctrl);
}
lead.updateRotors(dt);
lead.syncMesh();
// ---- record lead pose ----
recorder.update(simDt, lead.pos, lead.quat);
// ---- bots ----
bots.update({ pos: lead.pos, quat: lead.quat, yaw: lead.yaw, vel: lead.vel }, simDt, simTime, Config.values, wind);
// ---- flags (cloth) ----
flags.update(lead, LEAD_COLOR, bots.bots, simDt, simTime, Config.values, wind);
// ---- trails ----
if (Config.values.showTrails) updateTrails();
// ---- render ----
renderer.render(scene, camera);
updateHud(dt);
}
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
/* ------------------------------------------------------------------ *
* Boot
* ------------------------------------------------------------------ */
UI.init(hooks);
UI.setChoreo(choreoInfo());
updateHudMode();
UI.setInputLabel(input.activeLabel());
requestAnimationFrame(animate);
})();js/noise.js
/**
* noise.js — Seeded 3D Perlin noise + a wind/turbulence field built on top of it.
* Used to generate realistic, slowly-varying disturbances (wind + turbulence).
*/
(function () {
'use strict';
/* ------------------------------------------------------------------ *
* Perlin noise (improved Perlin, Ken Perlin 2002), seeded.
* ------------------------------------------------------------------ */
class PerlinNoise {
constructor(seed = 0) {
this.perm = new Uint8Array(512);
let s = seed >>> 0;
const rand = () => {
// xorshift-ish deterministic PRNG
s = (s * 1664525 + 1013904223) >>> 0;
return s / 4294967296;
};
const p = new Uint8Array(256);
for (let i = 0; i < 256; i++) p[i] = i;
for (let i = 255; i > 0; i--) {
const j = Math.floor(rand() * (i + 1));
const t = p[i]; p[i] = p[j]; p[j] = t;
}
for (let i = 0; i < 512; i++) this.perm[i] = p[i & 255];
}
fade(t) { return t * t * t * (t * (t * 6 - 15) + 10); }
lerp(t, a, b) { return a + t * (b - a); }
grad(h, x, y, z) {
h &= 15;
const u = h < 8 ? x : y;
const v = h < 4 ? y : (h === 12 || h === 14 ? x : z);
return ((h & 1) === 0 ? u : -u) + ((h & 2) === 0 ? v : -v);
}
noise(x, y, z) {
const p = this.perm;
const X = Math.floor(x) & 255;
const Y = Math.floor(y) & 255;
const Z = Math.floor(z) & 255;
x -= Math.floor(x); y -= Math.floor(y); z -= Math.floor(z);
const u = this.fade(x), v = this.fade(y), w = this.fade(z);
const A = p[X] + Y, AA = p[A] + Z, AB = p[A + 1] + Z;
const B = p[X + 1] + Y, BA = p[B] + Z, BB = p[B + 1] + Z;
return this.lerp(w,
this.lerp(v,
this.lerp(u, this.grad(p[AA], x, y, z), this.grad(p[BA], x - 1, y, z)),
this.lerp(u, this.grad(p[AB], x, y - 1, z), this.grad(p[BB], x - 1, y - 1, z))),
this.lerp(v,
this.lerp(u, this.grad(p[AA + 1], x, y, z - 1), this.grad(p[BA + 1], x - 1, y, z - 1)),
this.lerp(u, this.grad(p[AB + 1], x, y - 1, z - 1), this.grad(p[BB + 1], x - 1, y - 1, z - 1))));
}
}
/* ------------------------------------------------------------------ *
* WindField — combines a base wind vector, gusts and high-frequency
* turbulence (force + torque), all derived from Perlin noise.
* ------------------------------------------------------------------ */
class WindField {
constructor(seed = 42) {
// low-frequency noise for base wind + gusts
this.gx = new PerlinNoise(seed);
this.gy = new PerlinNoise(seed + 101);
this.gz = new PerlinNoise(seed + 202);
// higher-frequency noise for turbulence
this.tx = new PerlinNoise(seed + 303);
this.ty = new PerlinNoise(seed + 404);
this.tz = new PerlinNoise(seed + 505);
this.rx = new PerlinNoise(seed + 606);
this.ry = new PerlinNoise(seed + 707);
this.rz = new PerlinNoise(seed + 808);
// reusable outputs to avoid per-frame allocation
this.vel = new THREE.Vector3();
this.turb = new THREE.Vector3();
this.torque = new THREE.Vector3();
}
/**
* Compute wind velocity + turbulence (force & torque) at a world position.
* @param {THREE.Vector3} pos
* @param {number} t elapsed simulation time (seconds)
* @param {object} P config values
*/
sample(pos, t, P) {
const px = pos.x, py = pos.y, pz = pos.z;
if (!P.windEnabled) {
this.vel.set(0, 0, 0);
this.turb.set(0, 0, 0);
this.torque.set(0, 0, 0);
return this;
}
const s = P.noiseScale;
const ts = P.timeScale;
const heading = THREE.MathUtils.degToRad(P.windHeading);
const bx = Math.sin(heading) * P.windSpeed;
const bz = Math.cos(heading) * P.windSpeed;
const g = P.windGust;
// base wind + low-frequency gusts
this.vel.x = bx + this.gx.noise(px * s, py * s * 0.6, t * ts) * g;
this.vel.y = this.gy.noise(px * s + 50, py * s + 50, t * ts * 1.3) * g * 0.35;
this.vel.z = bz + this.gz.noise(px * s + 120, py * s * 0.6 + 120, t * ts * 0.9) * g;
// turbulence — higher spatial & temporal frequency
const st = s * 5;
const tf = ts * 5;
const T = P.turbulence;
this.turb.x = this.tx.noise(px * st, py * st, t * tf) * T;
this.turb.y = this.ty.noise(px * st + 77, py * st + 77, t * tf * 1.2) * T * 0.6;
this.turb.z = this.tz.noise(px * st + 154, py * st + 154, t * tf * 0.8) * T;
const TT = P.turbTorque;
this.torque.x = this.rx.noise(px * st, py * st, t * tf * 1.1) * TT;
this.torque.y = this.ry.noise(px * st + 31, py * st + 31, t * tf) * TT * 0.7;
this.torque.z = this.rz.noise(px * st + 62, py * st + 62, t * tf * 0.9) * TT;
return this;
}
}
window.PerlinNoise = PerlinNoise;
window.WindField = WindField;
})();
js/physics.js
/**
* physics.js — rigid-body quadcopter dynamics.
*
* The drone is a point mass with a full 3D attitude (quaternion).
* Control input is "desired up vector + desired yaw + throttle":
* - Manual input supplies roll/pitch tilt + yaw rate + throttle.
* - Autopilot (bots) supplies a desired up vector + yaw + throttle directly.
*
* Attitude control is a cascade, like a real flight controller:
* attitude error (rotation vector, body frame)
* -> P -> desired body rates (clamped: max tilt rate, max yaw rate)
* -> rate loop -> torque command (clamped: max tilt torque, max yaw torque)
* -> first-order motor lag -> real torque -> angular acceleration
* Thrust also goes through the motor lag. Everything is integrated in fixed
* sub-steps (<= 1/120 s), so the loop stays stable at any frame rate.
*
* The desired orientation is built directly from (up vector, heading) as a
* rotation basis — no Euler angles, no twist coupling between tilt and yaw.
*/
(function () {
'use strict';
const AXIS_Y = new THREE.Vector3(0, 1, 0);
const ZERO = new THREE.Vector3();
const DEG = Math.PI / 180;
const clamp = THREE.MathUtils.clamp;
const GROUND_Y = 0;
// Flight volume: 5 × 5 × 3 m (X × Z × Y), centred on the XZ origin.
const ROOM_BOUNDS = {
min: new THREE.Vector3(-2.5, 0, -2.5),
max: new THREE.Vector3(2.5, 3, 2.5),
};
const MAX_SUBSTEP = 1 / 120;
const MAX_SUBSTEPS = 8;
class Quadcopter {
constructor(params, mesh, bounds) {
this.params = params; // Config.values reference
this.mesh = mesh; // THREE.Group with .props (array of spin groups)
this.bounds = bounds || ROOM_BOUNDS;
this.pos = new THREE.Vector3();
this.vel = new THREE.Vector3();
this.quat = new THREE.Quaternion();
this.angVel = new THREE.Vector3(); // world frame
this.yaw = 0;
this.rotorSpeed = 0;
// motor state (first-order lag)
this.thrustAct = 0; // N
this.torqueAct = new THREE.Vector3(); // body frame, N·m
// current target (set once per step)
this._thrustCmd = 0;
this._yawRateLimit = 0;
this._qDes = new THREE.Quaternion();
// scratch (avoid per-frame allocation)
this._up = new THREE.Vector3();
this._hd = new THREE.Vector3();
this._xb = new THREE.Vector3();
this._zb = new THREE.Vector3();
this._v1 = new THREE.Vector3();
this._v2 = new THREE.Vector3();
this._v3 = new THREE.Vector3();
this._v4 = new THREE.Vector3();
this._v5 = new THREE.Vector3();
this._q1 = new THREE.Quaternion();
this._q2 = new THREE.Quaternion();
this._dq = new THREE.Quaternion();
this._m4 = new THREE.Matrix4();
}
reset(pos, yaw = 0) {
this.pos.copy(pos);
this.vel.set(0, 0, 0);
this.quat.setFromAxisAngle(AXIS_Y, yaw);
this.angVel.set(0, 0, 0);
this.yaw = yaw;
this.torqueAct.set(0, 0, 0);
this.thrustAct = this.params.mass * this.params.gravity; // start at hover thrust
}
/**
* Advance the simulation.
* @param {number} dt
* @param {object} ctrl
* { throttle, roll, pitch, yawRate } (manual)
* or { throttle, up, yaw } (autopilot)
* @param {object|null} wind { vel, turb, torque } (THREE.Vector3, may be null)
*/
step(dt, ctrl, wind) {
const p = this.params;
this._resolveTarget(ctrl, dt);
const n = Math.min(MAX_SUBSTEPS, Math.max(1, Math.ceil(dt / MAX_SUBSTEP)));
const h = dt / n;
for (let i = 0; i < n; i++) this._substep(h, wind);
this.rotorSpeed = 25 + clamp(this.thrustAct / p.maxThrust, 0, 1) * 220;
}
/** Build target attitude (from up vector + heading) and thrust command. */
_resolveTarget(ctrl, dt) {
const p = this.params;
const maxTilt = p.maxTilt * DEG;
const up = this._up;
let yawT;
if (ctrl.up) {
up.copy(ctrl.up);
yawT = ctrl.yaw;
} else {
// manual: tilt is relative to the drone's current yaw
up.set(Math.sin(ctrl.pitch * maxTilt), 1, Math.sin(ctrl.roll * maxTilt));
this.yaw += ctrl.yawRate * p.yawRate * DEG * dt;
yawT = this.yaw;
up.normalize().applyAxisAngle(AXIS_Y, yawT);
}
up.normalize();
// hard tilt limit — no source may ask for more than the envelope allows
const s = Math.hypot(up.x, up.z);
if (s > 1e-6) {
const tilt = Math.atan2(s, up.y);
if (tilt > maxTilt) {
const k = Math.sin(maxTilt) / s;
up.set(up.x * k, Math.cos(maxTilt), up.z * k);
}
}
// desired rotation basis: columns = (forward, up, right)
// right = heading × up
// forward = up × right (= heading projected onto the plane ⟂ up)
this._hd.set(Math.cos(yawT), 0, -Math.sin(yawT));
const zb = this._zb.crossVectors(this._hd, up);
if (zb.lengthSq() < 1e-8) zb.set(0, 0, 1);
zb.normalize();
const xb = this._xb.crossVectors(up, zb);
this._m4.makeBasis(xb, up, zb);
this._qDes.setFromRotationMatrix(this._m4);
this._thrustCmd = clamp(ctrl.throttle, 0, 1) * p.maxThrust;
// per-drone yaw-rate limit (bots turn slower than the lead), never above the airframe limit
this._yawRateLimit = Math.min(p.yawRate, ctrl.maxYawRate || p.yawRate) * DEG;
}
_substep(h, wind) {
const p = this.params;
const m = p.mass;
// ---- attitude error as rotation vector in the body frame ----
const qInv = this._q1.copy(this.quat).invert();
const qErr = this._q2.multiplyQuaternions(qInv, this._qDes);
let vx = qErr.x, vy = qErr.y, vz = qErr.z, w = qErr.w;
if (w < 0) { vx = -vx; vy = -vy; vz = -vz; w = -w; } // shortest path
const sn = Math.sqrt(vx * vx + vy * vy + vz * vz);
let ex = 0, ey = 0, ez = 0;
if (sn > 1e-9) {
const k = (2 * Math.atan2(sn, w)) / sn;
ex = vx * k; ey = vy * k; ez = vz * k;
}
// ---- outer loop: attitude -> desired body rates ----
const wb = this._v1.copy(this.angVel).applyQuaternion(qInv);
const maxRate = p.maxRate * DEG;
const maxYawRate = this._yawRateLimit;
let rdx = p.Kp * ex, rdz = p.Kp * ez;
const rm = Math.hypot(rdx, rdz);
if (rm > maxRate) { rdx *= maxRate / rm; rdz *= maxRate / rm; }
const rdy = clamp(p.Kp * ey, -maxYawRate, maxYawRate);
// ---- inner loop: rate error -> torque command (body: x roll, y yaw, z pitch) ----
let tx = p.Kd * (rdx - wb.x) * p.inertiaPitch;
let tz = p.Kd * (rdz - wb.z) * p.inertiaPitch;
let ty = p.Kd * (rdy - wb.y) * p.inertiaYaw;
const tm = Math.hypot(tx, tz);
if (tm > p.maxTorque) { const k = p.maxTorque / tm; tx *= k; tz *= k; }
ty = clamp(ty, -p.maxYawTorque, p.maxYawTorque);
// ---- motor lag (thrust + torque) ----
const kl = 1 - Math.exp(-h / Math.max(1e-3, p.motorLag));
const ta = this.torqueAct;
ta.x += (tx - ta.x) * kl;
ta.y += (ty - ta.y) * kl;
ta.z += (tz - ta.z) * kl;
this.thrustAct += (this._thrustCmd - this.thrustAct) * kl;
// ---- disturbance torque (turbulence), not lagged ----
let Tx = ta.x, Ty = ta.y, Tz = ta.z;
if (wind) {
const tw = this._v3.copy(wind.torque).applyQuaternion(qInv);
Tx += tw.x; Ty += tw.y; Tz += tw.z;
}
// angular acceleration (body) -> world
const ab = this._v2.set(Tx / p.inertiaPitch, Ty / p.inertiaYaw, Tz / p.inertiaPitch);
const alphaW = ab.applyQuaternion(this.quat);
this.angVel.addScaledVector(alphaW, h);
// integrate orientation (exponential map, exact for constant rate)
const aw = this.angVel.length();
if (aw > 1e-9) {
this._dq.setFromAxisAngle(this._v4.copy(this.angVel).multiplyScalar(1 / aw), aw * h);
this.quat.premultiply(this._dq).normalize();
}
// ---- forces ----
const F = this._v5.set(0, 0, 0);
const upWorld = this._v4.set(0, 1, 0).applyQuaternion(this.quat);
F.addScaledVector(upWorld, this.thrustAct); // rotor thrust
F.y -= m * p.gravity; // gravity
// drag relative to wind (air speed)
const relVel = this._v3.copy(this.vel).sub(wind ? wind.vel : ZERO);
const speed = relVel.length();
F.addScaledVector(relVel, -(p.drag + p.dragQuad * speed));
if (wind) F.add(wind.turb); // turbulence force
// integrate velocity & position
this.vel.addScaledVector(F, h / m);
this.pos.addScaledVector(this.vel, h);
// room boundary (soft reflection on all six faces, floor included)
const b = this.bounds;
if (this.pos.x < b.min.x) { this.pos.x = b.min.x; if (this.vel.x < 0) this.vel.x *= -0.25; }
if (this.pos.x > b.max.x) { this.pos.x = b.max.x; if (this.vel.x > 0) this.vel.x *= -0.25; }
if (this.pos.y < b.min.y) { this.pos.y = b.min.y; if (this.vel.y < 0) this.vel.y *= -0.25; }
if (this.pos.y > b.max.y) { this.pos.y = b.max.y; if (this.vel.y > 0) this.vel.y *= -0.25; }
if (this.pos.z < b.min.z) { this.pos.z = b.min.z; if (this.vel.z < 0) this.vel.z *= -0.25; }
if (this.pos.z > b.max.z) { this.pos.z = b.max.z; if (this.vel.z > 0) this.vel.z *= -0.25; }
}
updateRotors(dt) {
if (!this.mesh || !this.mesh.props) return;
const spin = this.rotorSpeed * dt;
for (const prop of this.mesh.props) prop.rotation.y += spin;
}
syncMesh() {
if (!this.mesh) return;
this.mesh.position.copy(this.pos);
this.mesh.quaternion.copy(this.quat);
}
}
window.Quadcopter = Quadcopter;
window.ROOM_BOUNDS = ROOM_BOUNDS;
window.GROUND_Y = GROUND_Y;
})();js/ui.js
/**
* ui.js — builds the side panel (parameter sliders, bot cards, choreography
* controls and state save/load) and wires it to the simulation via hooks.
*/
(function () {
'use strict';
const STATES_KEY = 'droneSim.states';
function el(tag, cls, text) {
const node = document.createElement(tag);
if (cls) node.className = cls;
if (text != null) node.textContent = text;
return node;
}
function fmt(v, unit) {
const a = Math.abs(v);
let s = a >= 100 ? v.toFixed(0) : a >= 10 ? v.toFixed(1) : v.toFixed(2);
if (v !== 0 && a < 1) s = v.toFixed(3);
return unit ? s + ' ' + unit : s;
}
const UI = {
hooks: null,
container: null,
_bindings: {},
init(hooks) {
this.hooks = hooks;
this.container = document.getElementById('panelContent');
this.container.innerHTML = '';
this._bindings = {};
this._buildControls();
this._buildBots();
this._buildChoreo();
this._buildStates();
this._bindPanelToggle();
},
/* ---------------------------------------------------------------- */
_bindPanelToggle() {
const panel = document.getElementById('panel');
const toggle = document.getElementById('panelToggle');
const close = document.getElementById('panelClose');
const setOpen = (open) => {
panel.classList.toggle('closed', !open);
toggle.style.display = open ? 'none' : 'flex';
};
toggle.addEventListener('click', () => setOpen(true));
close.addEventListener('click', () => setOpen(false));
window.addEventListener('keydown', (e) => {
if (e.code === 'Escape' && !panel.classList.contains('closed')) setOpen(false);
});
},
/* ---------------------------------------------------------------- */
_section(title) {
const sec = el('section', 'section');
const head = el('button', 'section-head');
head.innerHTML = '<span>' + title + '</span><span class="chev">▾</span>';
const body = el('div', 'section-body');
head.addEventListener('click', () => sec.classList.toggle('closed'));
sec.append(head, body);
this.container.append(sec);
return body;
},
_slider(label, min, max, step, value, unit, onChange, key) {
const field = el('div', 'field');
const row = el('div', 'field-row');
const lbl = el('span', 'field-label', label);
const val = el('span', 'field-value', fmt(value, unit));
row.append(lbl, val);
const range = el('input');
range.type = 'range';
range.min = min; range.max = max; range.step = step;
range.value = value;
range.addEventListener('input', () => {
const v = parseFloat(range.value);
val.textContent = fmt(v, unit);
onChange(v);
});
field.append(row, range);
if (key) {
this._bindings[key] = { set: (v) => { range.value = v; val.textContent = fmt(v, unit); } };
}
return field;
},
_toggle(label, checked, onChange, key) {
const lab = el('label', 'switch');
const input = el('input');
input.type = 'checkbox';
input.checked = checked;
const track = el('span', 'track');
lab.append(input, track, document.createTextNode(' ' + label));
input.addEventListener('change', () => onChange(input.checked));
if (key) {
this._bindings[key] = { set: (v) => { input.checked = v; } };
}
return { el: lab, input };
},
/** Update slider/checkbox positions to match Config.values (after load). */
refreshParams() {
for (const key in this._bindings) {
if (key in Config.values) this._bindings[key].set(Config.values[key]);
}
},
/** Update a single control without firing its change handler. */
setControl(key, v) {
if (this._bindings[key]) this._bindings[key].set(v);
},
/* ---------------------------------------------------------------- */
_buildControls() {
// Flight controls help
const flight = this._section('Flight Controls');
flight.append(el('p', 'hint',
'Xbox (Mode 2): left stick = up/down + yaw, right stick = forward/strafe. ' +
'Neutral sticks = hold position & altitude. ' +
'A reset · Y record · X play · B loop · Back clear.'));
flight.append(el('p', 'hint',
'Keyboard: W/S up/down · A/D yaw · arrows forward/strafe · Space/Shift up/down. ' +
'Release = hold. R reset · G record · P play · L loop · C clear · T trails · Esc panel.'));
const btnRow = el('div', 'btn-row');
const resetBtn = el('button', 'btn', '↺ Reset drones');
resetBtn.addEventListener('click', () => this.hooks.onReset());
btnRow.append(resetBtn);
flight.append(btnRow);
// parameter groups
for (const group of GROUP_ORDER) {
const body = this._section(GROUP_LABELS[group]);
for (const key in PARAM_DEFS) {
const def = PARAM_DEFS[key];
if (def.group !== group) continue;
if (def.type === 'bool') {
const wrap = el('div', 'field');
const t = this._toggle(def.label, Config.values[key], (v) => {
Config.values[key] = v;
this.hooks.onParamChanged(key, v);
}, key);
wrap.append(t.el);
body.append(wrap);
} else {
body.append(this._slider(def.label, def.min, def.max, def.step,
Config.values[key], def.unit, (v) => {
Config.values[key] = v;
this.hooks.onParamChanged(key, v);
}, key));
}
}
}
},
/* ---------------------------------------------------------------- */
_buildBots() {
const body = this._section('Drones & Offsets');
this.botsBody = body;
const addBtn = el('button', 'btn primary', '+ Add bot');
addBtn.addEventListener('click', () => {
const color = BOT_COLORS[Config.bots.length % BOT_COLORS.length];
Config.bots.push({
name: 'Bot ' + (Config.bots.length + 1),
color,
behavior: 'follow',
offset: { x: 1.0, y: 0, z: 0 },
followStrength: 1,
enabled: true,
});
this.hooks.onBotsChanged();
this.refreshBots();
});
body.append(addBtn);
body.append(el('div', 'sep'));
const anchorRow = el('div', 'btn-row');
const anchorBtn = el('button', 'btn', '⌖ Set hover anchors');
anchorBtn.addEventListener('click', () => this.hooks.onSetAnchors());
anchorRow.append(anchorBtn);
body.append(anchorRow);
this.botsList = el('div');
body.append(this.botsList);
this.refreshBots();
},
refreshBots() {
if (!this.botsList) return;
this.botsList.innerHTML = '';
Config.bots.forEach((bot, index) => {
this.botsList.append(this._botCard(bot, index));
});
},
_botCard(bot, index) {
const hooks = this.hooks;
const card = el('div', 'bot-card');
const head = el('div', 'bot-card-head');
const swatch = el('input', 'bot-swatch');
swatch.type = 'color';
swatch.value = bot.color;
swatch.addEventListener('input', () => { bot.color = swatch.value; hooks.onBotsChanged(); });
const name = el('input', 'bot-name');
name.type = 'text';
name.value = bot.name;
name.addEventListener('change', () => { bot.name = name.value || ('Bot ' + (index + 1)); });
const enable = el('label', 'switch');
const enIn = el('input');
enIn.type = 'checkbox';
enIn.checked = bot.enabled !== false;
const enTrack = el('span', 'track');
enIn.addEventListener('change', () => { bot.enabled = enIn.checked; hooks.onBotsChanged(); });
enable.append(enIn, enTrack);
const rm = el('button', 'bot-remove', '✕');
rm.title = 'Remove bot';
rm.addEventListener('click', () => {
Config.bots.splice(index, 1);
hooks.onBotsChanged();
this.refreshBots();
});
head.append(swatch, name, enable, rm);
card.append(head);
const behField = el('div', 'field');
const sel = el('select');
BEHAVIORS.forEach((b) => {
const opt = el('option', null, b.label);
opt.value = b.id;
if (bot.behavior === b.id) opt.selected = true;
sel.append(opt);
});
sel.addEventListener('change', () => { bot.behavior = sel.value; hooks.onBotsChanged(); });
behField.append(sel);
card.append(behField);
const off = (axis, label) => this._slider(label, -2.5, 2.5, 0.05, bot.offset[axis], 'm', (v) => {
bot.offset[axis] = v;
hooks.onBotsChanged();
});
card.append(off('x', 'Offset X (fwd)'));
card.append(off('y', 'Offset Y (up)'));
card.append(off('z', 'Offset Z (right)'));
card.append(this._slider('Follow strength', 0.1, 3, 0.05, bot.followStrength, '', (v) => {
bot.followStrength = v;
hooks.onBotsChanged();
}));
return card;
},
/* ---------------------------------------------------------------- */
_buildChoreo() {
const body = this._section('Choreography');
this.recBtn = el('button', 'btn', '● Record');
this.recBtn.addEventListener('click', () => this.hooks.onRecord());
this.playBtn = el('button', 'btn', '▶ Play');
this.playBtn.addEventListener('click', () => this.hooks.onPlay());
const clearBtn = el('button', 'btn danger', 'Clear track');
clearBtn.addEventListener('click', () => this.hooks.onClear());
const loopT = this._toggle('Loop', false, (v) => this.hooks.setLoop(v));
this.loopInput = loopT.input;
const row1 = el('div', 'btn-row');
row1.append(this.recBtn, this.playBtn);
body.append(row1);
const row2 = el('div', 'btn-row');
row2.append(clearBtn);
body.append(row2);
const loopWrap = el('div', 'field');
loopWrap.append(loopT.el);
body.append(loopWrap);
this.choreoInfo = el('p', 'hint', 'No track recorded yet.');
body.append(this.choreoInfo);
body.append(el('div', 'sep'));
const pb = PARAM_DEFS.playbackSpeed;
body.append(this._slider(pb.label, pb.min, pb.max, pb.step,
Config.values.playbackSpeed, pb.unit, (v) => {
Config.values.playbackSpeed = v;
this.hooks.onParamChanged('playbackSpeed', v);
}, 'playbackSpeed'));
body.append(el('p', 'hint',
'Record your flight, then press Play. The lead drone follows the track ' +
'while bots keep their offsets & behaviors — that is your choreography.'));
},
setChoreo(info) {
if (!this.recBtn) return;
this.recBtn.classList.toggle('active', info.recording);
this.recBtn.textContent = info.recording ? '■ Stop' : '● Record';
this.playBtn.classList.toggle('active', info.playing);
this.playBtn.textContent = info.playing ? '⏸ Stop' : '▶ Play';
this.loopInput.checked = info.loop;
if (info.hasTrack) {
this.choreoInfo.textContent =
'Track: ' + info.duration.toFixed(1) + 's · ' + info.frames + ' frames' +
(info.playing ? ' · playing' : '');
} else {
this.choreoInfo.textContent = 'No track recorded yet.';
}
},
/* ---------------------------------------------------------------- */
_buildStates() {
const body = this._section('States');
const nameRow = el('div', 'btn-row');
this.stateName = el('input', 'state-name');
this.stateName.type = 'text';
this.stateName.placeholder = 'State name…';
const saveBtn = el('button', 'btn primary', 'Save');
saveBtn.addEventListener('click', () => this._saveCurrent());
nameRow.append(this.stateName, saveBtn);
body.append(nameRow);
const ioRow = el('div', 'btn-row');
const exportBtn = el('button', 'btn', 'Export');
exportBtn.addEventListener('click', () => this._export());
const importBtn = el('button', 'btn', 'Import');
importBtn.addEventListener('click', () => this._import());
const defBtn = el('button', 'btn', 'Defaults');
defBtn.addEventListener('click', () => {
this.hooks.onResetDefaults();
this.refreshBots();
this.refreshParams();
});
ioRow.append(exportBtn, importBtn, defBtn);
body.append(ioRow);
this.statesList = el('div');
body.append(this.statesList);
this.refreshStates();
},
_readStates() {
try { return JSON.parse(localStorage.getItem(STATES_KEY)) || {}; }
catch (e) { return {}; }
},
_writeStates(s) { localStorage.setItem(STATES_KEY, JSON.stringify(s)); },
_saveCurrent() {
const name = (this.stateName.value || '').trim() || ('State ' + Date.now());
const states = this._readStates();
states[name] = this.hooks.serialize();
this._writeStates(states);
this.stateName.value = '';
this.refreshStates();
},
refreshStates() {
if (!this.statesList) return;
this.statesList.innerHTML = '';
const states = this._readStates();
const names = Object.keys(states);
if (names.length === 0) {
this.statesList.append(el('p', 'hint', 'No saved states.'));
return;
}
names.forEach((name) => {
const item = el('div', 'state-item');
const st = states[name];
const bots = st.bots ? st.bots.length : 0;
const meta = st.savedAt ? new Date(st.savedAt).toLocaleString() : '';
const nm = el('span', 'name', name);
const mt = el('span', 'meta', bots + ' drones · ' + meta);
const load = el('button', 'btn small', 'Load');
load.addEventListener('click', () => {
this.hooks.applyState(st);
this.refreshParams();
this.refreshBots();
});
const del = el('button', 'btn small danger', '✕');
del.title = 'Delete';
del.addEventListener('click', () => {
const s2 = this._readStates();
delete s2[name];
this._writeStates(s2);
this.refreshStates();
});
item.append(nm, mt, load, del);
this.statesList.append(item);
});
},
_export() {
const data = this.hooks.serialize();
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = el('a');
a.href = url;
a.download = 'drone-state.json';
a.click();
URL.revokeObjectURL(url);
},
_import() {
const input = el('input');
input.type = 'file';
input.accept = 'application/json';
input.addEventListener('change', () => {
const file = input.files && input.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
try {
const data = JSON.parse(reader.result);
this.hooks.applyState(data);
this.refreshParams();
this.refreshBots();
} catch (e) {
alert('Invalid state file.');
}
};
reader.readAsText(file);
});
input.click();
},
setInputLabel(label) {
const elNode = document.getElementById('hud-input');
if (elNode) elNode.textContent = label;
},
};
window.UI = UI;
})();
style.css
:root {
--bg: #ffffff;
--panel-bg: #ffffff;
--border: #e5e7eb;
--border-strong: #d1d5db;
--text: #111827;
--text-muted: #6b7280;
--accent: #111827;
--accent-soft: #f3f4f6;
--rec: #ef4444;
--shadow: 0 1px 3px rgba(0,0,0,0.08), 0 8px 24px rgba(0,0,0,0.06);
--radius: 10px;
--mono: ui-monospace, "SF Mono", "Cascadia Code", "Consolas", monospace;
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}
* { box-sizing: border-box; }
html, body {
margin: 0;
height: 100%;
overflow: hidden;
font-family: var(--sans);
color: var(--text);
background: var(--bg);
-webkit-font-smoothing: antialiased;
}
#app { position: fixed; inset: 0; }
#canvas { display: block; width: 100%; height: 100%; }
/* ---------- HUD ---------- */
#hud {
position: absolute;
top: 16px;
left: 16px;
display: flex;
flex-direction: column;
gap: 8px;
pointer-events: none;
user-select: none;
}
#hud-status { display: flex; gap: 6px; align-items: center; }
.chip {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.02em;
padding: 4px 9px;
border-radius: 999px;
background: rgba(255,255,255,0.85);
border: 1px solid var(--border);
color: var(--text-muted);
text-transform: uppercase;
}
.chip.hidden { display: none; }
#hud-rec { color: var(--rec); border-color: rgba(239,68,68,0.4); }
#hud-stats {
font-size: 11px;
color: var(--text-muted);
font-family: var(--mono);
display: flex;
gap: 12px;
}
/* ---------- Panel toggle ---------- */
#panelToggle {
position: absolute;
top: 16px;
right: 16px;
width: 38px;
height: 38px;
border-radius: 50%;
border: 1px solid var(--border);
background: #fff;
color: var(--text);
font-size: 16px;
cursor: pointer;
box-shadow: var(--shadow);
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.15s ease;
}
#panelToggle:hover { transform: scale(1.05); }
/* ---------- Panel ---------- */
#panel {
position: absolute;
top: 0;
right: 0;
height: 100%;
width: 340px;
background: var(--panel-bg);
border-left: 1px solid var(--border);
box-shadow: var(--shadow);
display: flex;
flex-direction: column;
transform: translateX(0);
transition: transform 0.25s cubic-bezier(0.4,0,0.2,1);
z-index: 10;
}
#panel.closed { transform: translateX(100%); }
#panelHeader {
padding: 18px 18px 14px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: flex-start;
justify-content: space-between;
}
#panelHeader h1 { margin: 0; font-size: 17px; font-weight: 700; letter-spacing: -0.01em; }
#panelHeader .subtitle { margin: 3px 0 0; font-size: 12px; color: var(--text-muted); }
#panelClose {
border: none;
background: transparent;
color: var(--text-muted);
font-size: 14px;
cursor: pointer;
padding: 4px;
}
#panelClose:hover { color: var(--text); }
#panelContent {
flex: 1;
overflow-y: auto;
padding: 6px 0 24px;
}
/* ---------- Sections ---------- */
.section { border-bottom: 1px solid var(--border); }
.section:last-child { border-bottom: none; }
.section-head {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 14px 18px;
background: none;
border: none;
cursor: pointer;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--text);
}
.section-head:hover { background: var(--accent-soft); }
.section-head .chev { transition: transform 0.2s ease; color: var(--text-muted); font-size: 10px; }
.section.closed .chev { transform: rotate(-90deg); }
.section.closed .section-body { display: none; }
.section-body { padding: 2px 18px 16px; }
.hint {
font-size: 12px;
color: var(--text-muted);
line-height: 1.5;
margin: 0 0 10px;
}
/* ---------- Controls ---------- */
.field { margin-bottom: 12px; }
.field-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 4px;
}
.field-label { font-size: 12px; color: var(--text); }
.field-value {
font-size: 11px;
font-family: var(--mono);
color: var(--text-muted);
background: var(--accent-soft);
padding: 1px 6px;
border-radius: 4px;
min-width: 46px;
text-align: right;
}
input[type="range"] {
-webkit-appearance: none;
width: 100%;
height: 4px;
border-radius: 4px;
background: var(--border);
outline: none;
cursor: pointer;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--accent);
border: 2px solid #fff;
box-shadow: 0 0 0 1px var(--border-strong);
cursor: pointer;
}
input[type="range"]:hover::-webkit-slider-thumb { transform: scale(1.1); }
input[type="number"] {
width: 100%;
padding: 6px 8px;
border: 1px solid var(--border);
border-radius: 6px;
font-size: 12px;
font-family: var(--mono);
color: var(--text);
background: #fff;
}
input[type="number"]:focus { outline: none; border-color: var(--accent); }
input[type="text"] {
width: 100%;
padding: 6px 8px;
border: 1px solid var(--border);
border-radius: 6px;
font-size: 12px;
color: var(--text);
}
select {
width: 100%;
padding: 6px 8px;
border: 1px solid var(--border);
border-radius: 6px;
font-size: 12px;
color: var(--text);
background: #fff;
cursor: pointer;
}
/* checkbox */
.switch {
display: inline-flex;
align-items: center;
gap: 8px;
cursor: pointer;
font-size: 12px;
}
.switch input { display: none; }
.switch .track {
width: 30px;
height: 18px;
border-radius: 999px;
background: var(--border-strong);
position: relative;
transition: background 0.2s ease;
}
.switch .track::after {
content: "";
position: absolute;
top: 2px;
left: 2px;
width: 14px;
height: 14px;
border-radius: 50%;
background: #fff;
transition: transform 0.2s ease;
}
.switch input:checked + .track { background: var(--accent); }
.switch input:checked + .track::after { transform: translateX(12px); }
/* ---------- Buttons ---------- */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 7px 12px;
border-radius: 8px;
border: 1px solid var(--border-strong);
background: #fff;
color: var(--text);
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: background 0.15s ease, transform 0.1s ease;
}
.btn:hover { background: var(--accent-soft); }
.btn:active { transform: scale(0.97); }
.btn.primary { background: var(--accent); color: #fff; border-color: var(--accent); }
.btn.primary:hover { background: #1f2937; }
.btn.danger { color: var(--rec); border-color: rgba(239,68,68,0.4); }
.btn.danger:hover { background: rgba(239,68,68,0.08); }
.btn.active { background: var(--rec); color: #fff; border-color: var(--rec); }
.btn.small { padding: 4px 9px; font-size: 11px; }
.btn-row { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 8px; }
.btn-row .btn { flex: 1; }
/* ---------- Bot cards ---------- */
.bot-card {
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 12px;
margin-bottom: 10px;
background: #fafafa;
}
.bot-card-head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.bot-swatch {
width: 14px;
height: 14px;
border-radius: 4px;
border: 1px solid var(--border-strong);
flex-shrink: 0;
}
.bot-name {
font-size: 13px;
font-weight: 600;
flex: 1;
border: 1px solid transparent;
background: transparent;
padding: 3px 5px;
border-radius: 5px;
color: var(--text);
min-width: 0;
}
.bot-name:hover { border-color: var(--border); }
.bot-name:focus { outline: none; border-color: var(--accent); background: #fff; }
input[type="color"].bot-swatch {
-webkit-appearance: none;
appearance: none;
width: 20px;
height: 20px;
padding: 0;
border: 1px solid var(--border-strong);
border-radius: 5px;
background: none;
cursor: pointer;
flex-shrink: 0;
}
input[type="color"].bot-swatch::-webkit-color-swatch-wrapper { padding: 0; }
input[type="color"].bot-swatch::-webkit-color-swatch { border: none; border-radius: 3px; }
input[type="color"].bot-swatch::-moz-color-swatch { border: none; border-radius: 3px; }
.bot-remove {
border: none;
background: transparent;
color: var(--text-muted);
cursor: pointer;
font-size: 14px;
padding: 2px 4px;
}
.bot-remove:hover { color: var(--rec); }
/* ---------- State list ---------- */
.state-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: 8px;
margin-bottom: 6px;
font-size: 12px;
}
.state-item .name { flex: 1; font-weight: 500; }
.state-item .meta { font-size: 10px; color: var(--text-muted); font-family: var(--mono); }
.sep { height: 1px; background: var(--border); margin: 12px 0; }
::-webkit-scrollbar { width: 10px; }
::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 8px; border: 2px solid #fff; }
::-webkit-scrollbar-track { background: transparent; }