CI & Release / Verify simulator (push) Skipped
CI & Release / Trivy scan (push) Skipped
CI & Release / Semantic Release (push) Skipped
CI & Release / Verify simulator (pull_request) Failing after 5s
CI & Release / Trivy scan (pull_request) Skipped
CI & Release / Semantic Release (pull_request) Skipped
Add optional Three.js scene, base-pad lifecycle, mycelial swarms, UFO transit steering, and algorithm selector (boids, APF, ACO, hypha).
1900 lines
55 KiB
TypeScript
1900 lines
55 KiB
TypeScript
// Swarm simulation core: pure functions over immutable-ish state.
|
|
// Models patrol flight, separation, obstacle avoidance, mesh links with
|
|
// pose-broadcast flashes and opportunistic bulk sync transfers.
|
|
|
|
export interface Vec {
|
|
x: number;
|
|
y: number;
|
|
}
|
|
|
|
export interface Drone {
|
|
id: number;
|
|
name?: string; // live mode: real drone_id from the data plane
|
|
pos: Vec;
|
|
vel: Vec;
|
|
heading: number; // radians
|
|
waypoint: number;
|
|
battery: number; // 0..100
|
|
channel: number; // Wi-Fi channel currently in use
|
|
alt?: number; // meters AGL — swarm layer when routeMode is perimeter
|
|
altVel?: number;
|
|
aware?: Map<number, TransitIntel>; // mesh-shared transit sightings
|
|
phase?: DronePhase;
|
|
dataBuffer?: number; // bytes queued for base offload
|
|
offloadProgress?: number; // 0..1 while on pad
|
|
landingQueued?: boolean;
|
|
}
|
|
|
|
export type DronePhase = "deploy" | "patrol" | "return" | "landed" | "takeoff";
|
|
|
|
export interface BasePad {
|
|
pos: Vec;
|
|
radius: number;
|
|
totalOffloaded: number;
|
|
offloadingIds: number[];
|
|
}
|
|
|
|
export interface TransitIntel {
|
|
id: number;
|
|
pos: Vec;
|
|
vel: Vec;
|
|
radius: number;
|
|
seenAt: number;
|
|
}
|
|
|
|
export interface Obstacle {
|
|
id: number;
|
|
pos: Vec;
|
|
radius: number;
|
|
moving: boolean;
|
|
vel: Vec;
|
|
/** Exit waypoint — transit steers through the interior toward this point. */
|
|
exit?: Vec;
|
|
alt?: number;
|
|
altVel?: number;
|
|
/** Nominal cruise speed (m/s) — used for throttle-only drone proximity response. */
|
|
transitCruise?: number;
|
|
}
|
|
|
|
export interface LinkStats {
|
|
key: string;
|
|
a: number;
|
|
b: number;
|
|
totalUp: number; // bytes, a -> b
|
|
totalDown: number; // bytes, b -> a
|
|
rate: number; // current bytes/s (both directions)
|
|
flash: number; // 0..1, decaying broadcast pulse
|
|
bulk: number; // remaining bytes of an in-flight bulk transfer
|
|
}
|
|
|
|
export type RouteMode = "default" | "perimeter";
|
|
|
|
export type SwarmAlgorithm =
|
|
| "perimeter"
|
|
| "boids"
|
|
| "apf"
|
|
| "aco"
|
|
| "hypha"
|
|
| "frontier"
|
|
| "wave"
|
|
| "mycelium";
|
|
|
|
export const SWARM_ALGORITHM_LABELS: Record<SwarmAlgorithm, string> = {
|
|
perimeter: "Perimeter slots",
|
|
boids: "Boids (Reynolds)",
|
|
apf: "Potential field (APF)",
|
|
aco: "Ant colony (ACO)",
|
|
hypha: "HyphaNet (link flow)",
|
|
frontier: "Frontier + prune",
|
|
wave: "Travelling wave",
|
|
mycelium: "Mycelium (combined)",
|
|
};
|
|
|
|
export interface FlightStats {
|
|
avgSpeed: number;
|
|
idlePct: number;
|
|
spinPct: number;
|
|
passOverPct: number;
|
|
bypassPct: number;
|
|
}
|
|
|
|
export interface FlightAccumulator {
|
|
speedSum: number;
|
|
samples: number;
|
|
idleSamples: number;
|
|
spinSamples: number;
|
|
passOverSamples: number;
|
|
bypassSamples: number;
|
|
}
|
|
|
|
export interface World {
|
|
t: number;
|
|
drones: Drone[];
|
|
obstacles: Obstacle[];
|
|
links: Map<string, LinkStats>;
|
|
totalBytes: number;
|
|
broadcasts: number;
|
|
routeMode?: RouteMode;
|
|
swarmAlgo?: SwarmAlgorithm;
|
|
perimeterHeat?: number[];
|
|
hyphaStrength?: number[];
|
|
flightAcc?: FlightAccumulator;
|
|
statsWindowStart?: number;
|
|
flightStats?: FlightStats;
|
|
basePad?: BasePad;
|
|
landingQueue?: number[];
|
|
}
|
|
|
|
const ESCORT_PER_TRANSIT = 4;
|
|
export { ESCORT_PER_TRANSIT };
|
|
|
|
export const AREA = 1000; // meters, vertical extent of the field
|
|
export const MAX_ASPECT = 2.2;
|
|
// Horizontal extent follows the display aspect ratio (set via makeWorld),
|
|
// so wide monitors get a genuinely wider patrol area, not empty margins.
|
|
let areaX = AREA;
|
|
export function areaWidth(): number {
|
|
return areaX;
|
|
}
|
|
|
|
export function basePadCenter(): Vec {
|
|
return { x: areaX / 2, y: AREA / 2 };
|
|
}
|
|
|
|
function padLandingSpot(id: number, pad: BasePad): Vec {
|
|
const angle = id * 2.399963229;
|
|
const r = pad.radius * 0.38;
|
|
return {
|
|
x: pad.pos.x + Math.cos(angle) * r,
|
|
y: pad.pos.y + Math.sin(angle) * r,
|
|
};
|
|
}
|
|
|
|
function distToPad(d: Drone, pad: BasePad): number {
|
|
return Math.hypot(d.pos.x - pad.pos.x, d.pos.y - pad.pos.y);
|
|
}
|
|
|
|
function landingSlotsUsed(drones: Drone[], pad: BasePad): number {
|
|
let used = 0;
|
|
for (const d of drones) {
|
|
if (d.phase === "landed" || d.phase === "takeoff") {
|
|
used += 1;
|
|
continue;
|
|
}
|
|
if (d.phase === "return" && distToPad(d, pad) < pad.radius * 2.8) used += 1;
|
|
}
|
|
return used;
|
|
}
|
|
|
|
function manageLandingQueue(drones: Drone[], pad: BasePad, queueIn: number[]): {
|
|
drones: Drone[];
|
|
landingQueue: number[];
|
|
} {
|
|
let queue = [...queueIn];
|
|
let dronesOut = drones.map((d) => ({ ...d }));
|
|
|
|
for (const d of dronesOut) {
|
|
if (d.phase !== "patrol" || d.landingQueued) continue;
|
|
const bufferFull = (d.dataBuffer ?? 0) >= DATA_OFFLOAD_THRESHOLD;
|
|
const lowBattery = d.battery < BATTERY_LAND_THRESHOLD;
|
|
if ((lowBattery || bufferFull) && !queue.includes(d.id)) {
|
|
if (d.battery < BATTERY_CRITICAL) queue.unshift(d.id);
|
|
else queue.push(d.id);
|
|
dronesOut = dronesOut.map((x) => (x.id === d.id ? { ...x, landingQueued: true } : x));
|
|
}
|
|
}
|
|
|
|
queue = queue.filter((id) => {
|
|
const d = dronesOut.find((x) => x.id === id);
|
|
return d && d.phase === "patrol";
|
|
});
|
|
|
|
queue.sort((a, b) => {
|
|
const da = dronesOut.find((x) => x.id === a);
|
|
const db = dronesOut.find((x) => x.id === b);
|
|
return (da?.battery ?? 100) - (db?.battery ?? 100);
|
|
});
|
|
|
|
let slots = landingSlotsUsed(dronesOut, pad);
|
|
while (queue.length > 0 && slots < MAX_CONCURRENT_LANDING) {
|
|
const id = queue.shift()!;
|
|
dronesOut = dronesOut.map((x) =>
|
|
x.id === id ? { ...x, phase: "return" as DronePhase, landingQueued: false } : x,
|
|
);
|
|
slots += 1;
|
|
}
|
|
|
|
return { drones: dronesOut, landingQueue: queue };
|
|
}
|
|
export const LINK_RANGE = 320;
|
|
const CRUISE = 28;
|
|
const SEPARATION = 55;
|
|
const POSE_BYTES = 46;
|
|
const POSE_HZ = 5;
|
|
// Steady per-link broadcast throughput (both directions), bytes/s
|
|
export const POSE_RATE_BYTES = POSE_BYTES * POSE_HZ;
|
|
const CHANNELS = [1, 6, 11, 36, 40, 44, 149, 157];
|
|
const ALT_MIN = 14;
|
|
const ALT_MAX = 42;
|
|
const ALT_SEP = 10;
|
|
const PERIMETER_SEGMENTS = 48;
|
|
const IDLE_SPEED = CRUISE * 0.25;
|
|
const SPIN_SPEED = CRUISE * 0.4;
|
|
const MIN_PATROL_SPEED = CRUISE * 0.4;
|
|
const HEADING_SPEED_MIN = 5;
|
|
const STATS_WINDOW_S = 8;
|
|
const BASE_PAD_RADIUS = 58;
|
|
const BATTERY_LAND_THRESHOLD = 32;
|
|
const BATTERY_CRITICAL = 16;
|
|
const BATTERY_DEPLOY_MIN = 88;
|
|
const DATA_OFFLOAD_THRESHOLD = 520_000;
|
|
const MAX_CONCURRENT_LANDING = 2;
|
|
const OFFLOAD_RATE = 140_000;
|
|
const OFFLOAD_PAD_SECONDS = 7;
|
|
const BATTERY_RECHARGE = 16;
|
|
const DATA_COLLECT_RATE = 4200;
|
|
const DETECT_RANGE = 210;
|
|
export { DETECT_RANGE, BASE_PAD_RADIUS };
|
|
|
|
function emptyFlightAcc(): FlightAccumulator {
|
|
return {
|
|
speedSum: 0,
|
|
samples: 0,
|
|
idleSamples: 0,
|
|
spinSamples: 0,
|
|
passOverSamples: 0,
|
|
bypassSamples: 0,
|
|
};
|
|
}
|
|
|
|
function flightAccToStats(acc: FlightAccumulator): FlightStats {
|
|
const n = Math.max(1, acc.samples);
|
|
return {
|
|
avgSpeed: acc.speedSum / n,
|
|
idlePct: (acc.idleSamples / n) * 100,
|
|
spinPct: (acc.spinSamples / n) * 100,
|
|
passOverPct: (acc.passOverSamples / n) * 100,
|
|
bypassPct: (acc.bypassSamples / n) * 100,
|
|
};
|
|
}
|
|
|
|
let seedState = 1234;
|
|
export function seed(v: number): void {
|
|
seedState = v || 1;
|
|
}
|
|
function rnd(): number {
|
|
// xorshift32 — deterministic runs
|
|
seedState ^= seedState << 13;
|
|
seedState ^= seedState >>> 17;
|
|
seedState ^= seedState << 5;
|
|
return ((seedState >>> 0) % 100000) / 100000;
|
|
}
|
|
|
|
// Two interleaved patrol routes: even drones circle the perimeter, odd
|
|
// drones fly an X through the center. Paths cross mid-field, so the mesh
|
|
// keeps bridging between the front and the back of the formation instead
|
|
// of stretching into disconnected segments along one loop. Routes are
|
|
// derived from the current field width, so they stretch on wide displays.
|
|
function perimeterRoute(): Vec[] {
|
|
return [
|
|
{ x: 120, y: 120 },
|
|
{ x: areaX - 120, y: 150 },
|
|
{ x: areaX - 150, y: AREA - 120 },
|
|
{ x: 150, y: AREA - 150 },
|
|
];
|
|
}
|
|
function crossRoute(): Vec[] {
|
|
return [
|
|
{ x: 150, y: 150 },
|
|
{ x: areaX - 150, y: AREA - 150 },
|
|
{ x: areaX - 150, y: 150 },
|
|
{ x: 150, y: AREA - 150 },
|
|
];
|
|
}
|
|
function routeOf(d: Drone, mode: RouteMode = "default"): Vec[] {
|
|
if (mode === "perimeter") return perimeterRoute();
|
|
return d.id % 2 === 0 ? perimeterRoute() : crossRoute();
|
|
}
|
|
|
|
function perimeterLoop(): Vec[] {
|
|
const route = perimeterRoute();
|
|
return [...route, route[0]];
|
|
}
|
|
|
|
function perimeterLength(): number {
|
|
const pts = perimeterLoop();
|
|
let len = 0;
|
|
for (let i = 0; i < pts.length - 1; i++) {
|
|
len += Math.hypot(pts[i + 1].x - pts[i].x, pts[i + 1].y - pts[i].y);
|
|
}
|
|
return len;
|
|
}
|
|
|
|
function perimeterPointAt(dist: number): Vec {
|
|
const pts = perimeterLoop();
|
|
const total = perimeterLength();
|
|
let s = ((dist % total) + total) % total;
|
|
let acc = 0;
|
|
for (let i = 0; i < pts.length - 1; i++) {
|
|
const seg = Math.hypot(pts[i + 1].x - pts[i].x, pts[i + 1].y - pts[i].y);
|
|
if (acc + seg >= s) {
|
|
const t = seg > 0 ? (s - acc) / seg : 0;
|
|
return {
|
|
x: pts[i].x + (pts[i + 1].x - pts[i].x) * t,
|
|
y: pts[i].y + (pts[i + 1].y - pts[i].y) * t,
|
|
};
|
|
}
|
|
acc += seg;
|
|
}
|
|
return { x: pts[0].x, y: pts[0].y };
|
|
}
|
|
|
|
function onFieldTransits(obstacles: Obstacle[]): Obstacle[] {
|
|
return obstacles.filter((o) => o.moving && !outOfTransit(o));
|
|
}
|
|
|
|
function hasLinkInRange(d: Drone, drones: Drone[]): boolean {
|
|
for (const other of drones) {
|
|
if (other.id === d.id) continue;
|
|
if (linkRange3d(d, other) <= LINK_RANGE) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function evenPerimeterSteer(
|
|
d: Drone,
|
|
droneCount: number,
|
|
ax: number,
|
|
ay: number,
|
|
): { ax: number; ay: number } {
|
|
return goalSeekSteer(d, droneCount, ax, ay, 2.8);
|
|
}
|
|
|
|
function lerpAngle(a: number, b: number, t: number): number {
|
|
let d = b - a;
|
|
while (d > Math.PI) d -= 2 * Math.PI;
|
|
while (d < -Math.PI) d += 2 * Math.PI;
|
|
return a + d * t;
|
|
}
|
|
|
|
function goalSeekSteer(
|
|
d: Drone,
|
|
n: number,
|
|
ax: number,
|
|
ay: number,
|
|
strength = 2.2,
|
|
): { ax: number; ay: number } {
|
|
const target = slotTarget(d, n);
|
|
const dx = target.x - d.pos.x;
|
|
const dy = target.y - d.pos.y;
|
|
const dist = Math.hypot(dx, dy) || 1;
|
|
const tangent = perimeterTangentAt(d, n);
|
|
if (dist < 85) {
|
|
ax += tangent.x * 3.1 + (dx / dist) * 0.55;
|
|
ay += tangent.y * 3.1 + (dy / dist) * 0.55;
|
|
} else {
|
|
ax += (dx / dist) * strength;
|
|
ay += (dy / dist) * strength;
|
|
}
|
|
return { ax, ay };
|
|
}
|
|
|
|
function resolveSteerStall(
|
|
d: Drone,
|
|
n: number,
|
|
ax: number,
|
|
ay: number,
|
|
vx: number,
|
|
vy: number,
|
|
): { ax: number; ay: number } {
|
|
const mag = Math.hypot(ax, ay);
|
|
const speed = Math.hypot(vx, vy);
|
|
const tangent = perimeterTangentAt(d, n);
|
|
const target = slotTarget(d, n);
|
|
const dx = target.x - d.pos.x;
|
|
const dy = target.y - d.pos.y;
|
|
const toSlot = Math.hypot(dx, dy) || 1;
|
|
|
|
if (mag < 0.08) {
|
|
return { ax: tangent.x * 3.6, ay: tangent.y * 3.6 };
|
|
}
|
|
|
|
const intentX = ax / mag;
|
|
const intentY = ay / mag;
|
|
const velDirX = speed > 0.01 ? vx / speed : intentX;
|
|
const velDirY = speed > 0.01 ? vy / speed : intentY;
|
|
const align = intentX * velDirX + intentY * velDirY;
|
|
|
|
if (speed < IDLE_SPEED || (speed < CRUISE * 0.48 && align < 0.2)) {
|
|
const blend = speed < IDLE_SPEED ? 3.2 : 2.4;
|
|
return {
|
|
ax: intentX * 0.2 + (tangent.x * 0.6 + (dx / toSlot) * 0.2) * blend,
|
|
ay: intentY * 0.2 + (tangent.y * 0.6 + (dy / toSlot) * 0.2) * blend,
|
|
};
|
|
}
|
|
return { ax, ay };
|
|
}
|
|
|
|
function integrateVel(
|
|
d: Drone,
|
|
ax: number,
|
|
ay: number,
|
|
cruiseMul: number,
|
|
phase?: DronePhase,
|
|
): { vx: number; vy: number; heading: number } {
|
|
const mag = Math.hypot(ax, ay) || 1;
|
|
const desiredVx = (ax / mag) * CRUISE * cruiseMul;
|
|
const desiredVy = (ay / mag) * CRUISE * cruiseMul;
|
|
const blend = phase === "patrol" || phase === "deploy" ? 0.34 : 0.24;
|
|
let vx = d.vel.x * (1 - blend) + desiredVx * blend;
|
|
let vy = d.vel.y * (1 - blend) + desiredVy * blend;
|
|
let speed = Math.hypot(vx, vy);
|
|
|
|
let minSpeed = 0;
|
|
if (phase === "patrol" || phase === "deploy") minSpeed = MIN_PATROL_SPEED * (phase === "deploy" ? 0.85 : 1);
|
|
else if (phase === "return") minSpeed = CRUISE * 0.3;
|
|
|
|
if (minSpeed > 0 && speed < minSpeed) {
|
|
vx = (ax / mag) * minSpeed;
|
|
vy = (ay / mag) * minSpeed;
|
|
speed = minSpeed;
|
|
}
|
|
|
|
let heading: number;
|
|
if (speed >= HEADING_SPEED_MIN) {
|
|
heading = Math.atan2(vy, vx);
|
|
} else if (mag > 0.08) {
|
|
heading = lerpAngle(d.heading, Math.atan2(ay, ax), 0.15);
|
|
} else {
|
|
heading = d.heading;
|
|
}
|
|
|
|
return { vx, vy, heading };
|
|
}
|
|
|
|
function linkSeekSteer(
|
|
d: Drone,
|
|
drones: Drone[],
|
|
ax: number,
|
|
ay: number,
|
|
): { ax: number; ay: number } {
|
|
if (hasLinkInRange(d, drones)) return { ax, ay };
|
|
let nearest: Drone | null = null;
|
|
let bestD = Infinity;
|
|
for (const other of drones) {
|
|
if (other.id === d.id) continue;
|
|
const dist = linkRange3d(d, other);
|
|
if (dist < bestD) {
|
|
bestD = dist;
|
|
nearest = other;
|
|
}
|
|
}
|
|
if (!nearest) return { ax, ay };
|
|
const dx = nearest.pos.x - d.pos.x;
|
|
const dy = nearest.pos.y - d.pos.y;
|
|
const mag = Math.hypot(dx, dy) || 1;
|
|
const pull = bestD > LINK_RANGE ? 0.38 : 0.18;
|
|
const ux = dx / mag;
|
|
const uy = dy / mag;
|
|
const intentMag = Math.hypot(ax, ay);
|
|
if (intentMag > 0.05 && ax * ux + ay * uy < 0) {
|
|
return { ax, ay };
|
|
}
|
|
return {
|
|
ax: ax * (1 - pull) + ux * pull * 2.4,
|
|
ay: ay * (1 - pull) + uy * pull * 2.4,
|
|
};
|
|
}
|
|
|
|
function slotTarget(d: Drone, n: number): Vec {
|
|
const slot = ((d.id + 0.5) / Math.max(1, n)) * perimeterLength();
|
|
return perimeterPointAt(slot);
|
|
}
|
|
|
|
function perimeterTangentAt(d: Drone, n: number): Vec {
|
|
const slot = ((d.id + 0.5) / Math.max(1, n)) * perimeterLength();
|
|
const p = perimeterPointAt(slot);
|
|
const ahead = perimeterPointAt(slot + perimeterLength() * 0.015);
|
|
const dx = ahead.x - p.x;
|
|
const dy = ahead.y - p.y;
|
|
const mag = Math.hypot(dx, dy) || 1;
|
|
return { x: dx / mag, y: dy / mag };
|
|
}
|
|
|
|
function patrolSteerBoids(d: Drone, drones: Drone[], ax: number, ay: number): { ax: number; ay: number } {
|
|
const n = drones.length;
|
|
({ ax, ay } = goalSeekSteer(d, n, ax, ay, 2.2));
|
|
|
|
let alignX = 0;
|
|
let alignY = 0;
|
|
let alignN = 0;
|
|
for (const other of drones) {
|
|
if (other.id === d.id) continue;
|
|
const odx = d.pos.x - other.pos.x;
|
|
const ody = d.pos.y - other.pos.y;
|
|
const od = Math.hypot(odx, ody);
|
|
if (od < SEPARATION * 1.5 && od > 0.01) {
|
|
const push = (SEPARATION * 1.5 - od) / (SEPARATION * 1.5);
|
|
ax += (odx / od) * push * 2.4;
|
|
ay += (ody / od) * push * 2.4;
|
|
}
|
|
const oSpeed = Math.hypot(other.vel.x, other.vel.y);
|
|
if (od < LINK_RANGE && oSpeed > 6) {
|
|
alignX += other.vel.x;
|
|
alignY += other.vel.y;
|
|
alignN += 1;
|
|
}
|
|
}
|
|
if (alignN > 0) {
|
|
const am = Math.hypot(alignX, alignY) || 1;
|
|
ax += (alignX / alignN / am) * 0.65;
|
|
ay += (alignY / alignN / am) * 0.65;
|
|
}
|
|
return linkSeekSteer(d, drones, ax, ay);
|
|
}
|
|
|
|
function patrolSteerApf(
|
|
d: Drone,
|
|
drones: Drone[],
|
|
obstacles: Obstacle[],
|
|
ax: number,
|
|
ay: number,
|
|
): { ax: number; ay: number } {
|
|
const n = drones.length;
|
|
({ ax, ay } = goalSeekSteer(d, n, ax, ay, 3.0));
|
|
|
|
for (const other of drones) {
|
|
if (other.id === d.id) continue;
|
|
const odx = d.pos.x - other.pos.x;
|
|
const ody = d.pos.y - other.pos.y;
|
|
const od = Math.hypot(odx, ody);
|
|
const influence = 95;
|
|
if (od < influence && od > 0.01) {
|
|
const rep = ((influence - od) / influence) ** 2 * 4.5;
|
|
ax += (odx / od) * rep;
|
|
ay += (ody / od) * rep;
|
|
}
|
|
}
|
|
for (const ob of obstacles) {
|
|
if (ob.moving) continue;
|
|
const odx = d.pos.x - ob.pos.x;
|
|
const ody = d.pos.y - ob.pos.y;
|
|
const od = Math.hypot(odx, ody);
|
|
const margin = ob.radius + 70;
|
|
if (od < margin && od > 0.01) {
|
|
const rep = ((margin - od) / margin) ** 2 * 3.5;
|
|
ax += (odx / od) * rep;
|
|
ay += (ody / od) * rep;
|
|
}
|
|
}
|
|
return linkSeekSteer(d, drones, ax, ay);
|
|
}
|
|
|
|
function nearestSegmentIndex(pos: Vec): number {
|
|
let best = 0;
|
|
let bestD = Infinity;
|
|
const total = perimeterLength();
|
|
const segLen = total / PERIMETER_SEGMENTS;
|
|
for (let i = 0; i < PERIMETER_SEGMENTS; i++) {
|
|
const p = perimeterPointAt(i * segLen + segLen * 0.5);
|
|
const dist = Math.hypot(pos.x - p.x, pos.y - p.y);
|
|
if (dist < bestD) {
|
|
bestD = dist;
|
|
best = i;
|
|
}
|
|
}
|
|
return best;
|
|
}
|
|
|
|
function updatePerimeterHeat(drones: Drone[], heat: number[], dt: number): number[] {
|
|
const next = heat.map((h) => h + dt * 0.12);
|
|
for (const d of drones) {
|
|
const seg = nearestSegmentIndex(d.pos);
|
|
next[seg] = Math.max(0, next[seg] - 1.4);
|
|
}
|
|
return next;
|
|
}
|
|
|
|
function patrolSteerAco(
|
|
d: Drone,
|
|
drones: Drone[],
|
|
heat: number[],
|
|
ax: number,
|
|
ay: number,
|
|
): { ax: number; ay: number } {
|
|
const n = drones.length;
|
|
const homeSlot =
|
|
Math.floor(((d.id + 0.5) / Math.max(1, n)) * PERIMETER_SEGMENTS) % PERIMETER_SEGMENTS;
|
|
let bestSeg = homeSlot;
|
|
let bestScore = -Infinity;
|
|
for (let off = -3; off <= 3; off++) {
|
|
const seg = (homeSlot + off + PERIMETER_SEGMENTS) % PERIMETER_SEGMENTS;
|
|
const need = heat[seg] ?? 0;
|
|
const score = need - Math.abs(off) * 0.35;
|
|
if (score > bestScore) {
|
|
bestScore = score;
|
|
bestSeg = seg;
|
|
}
|
|
}
|
|
const segLen = perimeterLength() / PERIMETER_SEGMENTS;
|
|
const target = perimeterPointAt(bestSeg * segLen + segLen * 0.5);
|
|
const dx = target.x - d.pos.x;
|
|
const dy = target.y - d.pos.y;
|
|
const dist = Math.hypot(dx, dy) || 1;
|
|
const tangent = perimeterTangentAt(d, n);
|
|
if (dist < 85) {
|
|
ax += tangent.x * 2.8 + (dx / dist) * 0.6;
|
|
ay += tangent.y * 2.8 + (dy / dist) * 0.6;
|
|
} else {
|
|
ax += (dx / dist) * 3.0;
|
|
ay += (dy / dist) * 3.0;
|
|
}
|
|
return linkSeekSteer(d, drones, ax, ay);
|
|
}
|
|
|
|
function updateHyphaStrength(
|
|
drones: Drone[],
|
|
links: Map<string, LinkStats>,
|
|
strength: number[],
|
|
dt: number,
|
|
): number[] {
|
|
const next = strength.map((s) => Math.max(0, s - dt * 0.35));
|
|
for (const d of drones) {
|
|
if (d.phase && d.phase !== "patrol") continue;
|
|
const seg = nearestSegmentIndex(d.pos);
|
|
next[seg] = Math.min(4.5, next[seg] + dt * 1.1);
|
|
}
|
|
for (const link of links.values()) {
|
|
if (link.rate < 4000) continue;
|
|
const a = drones[link.a];
|
|
const b = drones[link.b];
|
|
if (!a || !b) continue;
|
|
const boost = Math.min(2.2, link.rate / 70000) * dt * 2.4;
|
|
const sa = nearestSegmentIndex(a.pos);
|
|
const sb = nearestSegmentIndex(b.pos);
|
|
next[sa] = Math.min(5, next[sa] + boost);
|
|
next[sb] = Math.min(5, next[sb] + boost);
|
|
}
|
|
return next;
|
|
}
|
|
|
|
function segmentTarget(seg: number): Vec {
|
|
const segLen = perimeterLength() / PERIMETER_SEGMENTS;
|
|
return perimeterPointAt(seg * segLen + segLen * 0.5);
|
|
}
|
|
|
|
function steerTowardPoint(
|
|
d: Drone,
|
|
n: number,
|
|
target: Vec,
|
|
ax: number,
|
|
ay: number,
|
|
strength: number,
|
|
): { ax: number; ay: number } {
|
|
const dx = target.x - d.pos.x;
|
|
const dy = target.y - d.pos.y;
|
|
const dist = Math.hypot(dx, dy) || 1;
|
|
const tangent = perimeterTangentAt(d, n);
|
|
if (dist < 85) {
|
|
ax += tangent.x * 2.6 + (dx / dist) * 0.55;
|
|
ay += tangent.y * 2.6 + (dy / dist) * 0.55;
|
|
} else {
|
|
ax += (dx / dist) * strength;
|
|
ay += (dy / dist) * strength;
|
|
}
|
|
return { ax, ay };
|
|
}
|
|
|
|
function patrolSteerHypha(
|
|
d: Drone,
|
|
drones: Drone[],
|
|
heat: number[],
|
|
strength: number[],
|
|
ax: number,
|
|
ay: number,
|
|
): { ax: number; ay: number } {
|
|
const n = drones.length;
|
|
const home =
|
|
Math.floor(((d.id + 0.5) / Math.max(1, n)) * PERIMETER_SEGMENTS) % PERIMETER_SEGMENTS;
|
|
let bestSeg = home;
|
|
let bestScore = -Infinity;
|
|
for (let off = -5; off <= 5; off++) {
|
|
const seg = (home + off + PERIMETER_SEGMENTS) % PERIMETER_SEGMENTS;
|
|
const flow = strength[seg] ?? 0;
|
|
const need = heat[seg] ?? 0;
|
|
const score = flow * 1.4 + need * 0.45 - Math.abs(off) * 0.28;
|
|
if (score > bestScore) {
|
|
bestScore = score;
|
|
bestSeg = seg;
|
|
}
|
|
}
|
|
({ ax, ay } = steerTowardPoint(d, n, segmentTarget(bestSeg), ax, ay, 2.9));
|
|
return linkSeekSteer(d, drones, ax, ay);
|
|
}
|
|
|
|
function patrolSteerFrontier(
|
|
d: Drone,
|
|
drones: Drone[],
|
|
heat: number[],
|
|
strength: number[],
|
|
t: number,
|
|
ax: number,
|
|
ay: number,
|
|
): { ax: number; ay: number } {
|
|
const n = drones.length;
|
|
const slot = slotTarget(d, n);
|
|
const distSlot = Math.hypot(d.pos.x - slot.x, d.pos.y - slot.y);
|
|
const pathfinder =
|
|
distSlot > 115 || (d.id + Math.floor(t / 7)) % 4 === 0;
|
|
|
|
if (pathfinder) {
|
|
let bestSeg = 0;
|
|
let bestNeed = -Infinity;
|
|
for (let seg = 0; seg < PERIMETER_SEGMENTS; seg++) {
|
|
const need = (heat[seg] ?? 0) - (strength[seg] ?? 0) * 0.35;
|
|
if (need > bestNeed) {
|
|
bestNeed = need;
|
|
bestSeg = seg;
|
|
}
|
|
}
|
|
({ ax, ay } = steerTowardPoint(d, n, segmentTarget(bestSeg), ax, ay, 3.2));
|
|
} else {
|
|
({ ax, ay } = goalSeekSteer(d, n, ax, ay, 2.4));
|
|
const home =
|
|
Math.floor(((d.id + 0.5) / Math.max(1, n)) * PERIMETER_SEGMENTS) % PERIMETER_SEGMENTS;
|
|
const localFlow = strength[home] ?? 0;
|
|
if (localFlow < 0.8) {
|
|
({ ax, ay } = linkSeekSteer(d, drones, ax, ay));
|
|
}
|
|
}
|
|
return linkSeekSteer(d, drones, ax, ay);
|
|
}
|
|
|
|
function patrolSteerWave(
|
|
d: Drone,
|
|
drones: Drone[],
|
|
t: number,
|
|
ax: number,
|
|
ay: number,
|
|
): { ax: number; ay: number } {
|
|
const n = drones.length;
|
|
const total = perimeterLength();
|
|
const waveFront = (t * 42) % total;
|
|
const slot = ((d.id + 0.5) / Math.max(1, n)) * total;
|
|
const phase = (d.id / Math.max(1, n)) * total * 0.12;
|
|
const targetDist = (slot + waveFront * 0.22 + phase) % total;
|
|
({ ax, ay } = steerTowardPoint(d, n, perimeterPointAt(targetDist), ax, ay, 3.1));
|
|
return linkSeekSteer(d, drones, ax, ay);
|
|
}
|
|
|
|
function patrolSteerMycelium(
|
|
d: Drone,
|
|
drones: Drone[],
|
|
heat: number[],
|
|
strength: number[],
|
|
t: number,
|
|
ax: number,
|
|
ay: number,
|
|
): { ax: number; ay: number } {
|
|
const n = drones.length;
|
|
const home =
|
|
Math.floor(((d.id + 0.5) / Math.max(1, n)) * PERIMETER_SEGMENTS) % PERIMETER_SEGMENTS;
|
|
|
|
let exploreSeg = home;
|
|
let exploreScore = -Infinity;
|
|
for (let off = -6; off <= 6; off++) {
|
|
const seg = (home + off + PERIMETER_SEGMENTS) % PERIMETER_SEGMENTS;
|
|
const nutrient = (heat[seg] ?? 0) * 1.2 - (strength[seg] ?? 0) * 0.5;
|
|
const score = nutrient - Math.abs(off) * 0.22;
|
|
if (score > exploreScore) {
|
|
exploreScore = score;
|
|
exploreSeg = seg;
|
|
}
|
|
}
|
|
|
|
const slot = slotTarget(d, n);
|
|
const dxs = slot.x - d.pos.x;
|
|
const dys = slot.y - d.pos.y;
|
|
const toSlot = Math.hypot(dxs, dys) || 1;
|
|
const explore = segmentTarget(exploreSeg);
|
|
const dxe = explore.x - d.pos.x;
|
|
const dye = explore.y - d.pos.y;
|
|
const toExplore = Math.hypot(dxe, dye) || 1;
|
|
|
|
const pathfinder = (d.id + Math.floor(t / 5)) % 3 === 0;
|
|
const exploreW = pathfinder ? 0.52 : 0.28;
|
|
const slotW = 1 - exploreW;
|
|
const hypha = strength[home] ?? 0;
|
|
const flowW = Math.min(0.35, hypha * 0.12);
|
|
|
|
ax +=
|
|
(dxe / toExplore) * exploreW * 2.8 +
|
|
(dxs / toSlot) * slotW * 2.2 +
|
|
(dxe / toExplore) * flowW * 1.5;
|
|
ay +=
|
|
(dye / toExplore) * exploreW * 2.8 +
|
|
(dys / toSlot) * slotW * 2.2 +
|
|
(dye / toExplore) * flowW * 1.5;
|
|
|
|
if (hypha < 0.6 || !hasLinkInRange(d, drones)) {
|
|
({ ax, ay } = linkSeekSteer(d, drones, ax, ay));
|
|
}
|
|
return { ax, ay };
|
|
}
|
|
|
|
interface SteerMetrics {
|
|
passOver: boolean;
|
|
bypass: boolean;
|
|
}
|
|
|
|
function applyPeerDeconflict(
|
|
d: Drone,
|
|
drones: Drone[],
|
|
ax: number,
|
|
ay: number,
|
|
alt: number,
|
|
): { ax: number; ay: number; altAccel: number; metrics: SteerMetrics } {
|
|
let altAccel = 0;
|
|
let passOver = false;
|
|
let bypass = false;
|
|
const intentMag = Math.hypot(ax, ay) || 1;
|
|
const ix = ax / intentMag;
|
|
const iy = ay / intentMag;
|
|
|
|
for (const other of drones) {
|
|
if (other.id === d.id) continue;
|
|
const dx = d.pos.x - other.pos.x;
|
|
const dy = d.pos.y - other.pos.y;
|
|
const dist = Math.hypot(dx, dy);
|
|
if (dist > SEPARATION * 1.5 || dist < 0.01) continue;
|
|
|
|
const toOtherX = -dx / dist;
|
|
const toOtherY = -dy / dist;
|
|
const closing = ix * toOtherX + iy * toOtherY;
|
|
|
|
if (dist < SEPARATION) {
|
|
const push = (SEPARATION - dist) / SEPARATION;
|
|
ax += (dx / dist) * push * 1.6;
|
|
ay += (dy / dist) * push * 1.6;
|
|
}
|
|
|
|
if (closing > 0.25 && dist < 75) {
|
|
const preferOver = d.id < other.id || alt <= (other.alt ?? alt);
|
|
const canClimb = alt < ALT_MAX - 6 && (other.alt ?? 22) >= ALT_MIN + 6;
|
|
if (preferOver && canClimb) {
|
|
altAccel += 5.5 * closing;
|
|
passOver = true;
|
|
} else {
|
|
const perpX = -iy;
|
|
const perpY = ix;
|
|
const side = d.id < other.id ? 1 : -1;
|
|
ax += perpX * side * 2.0 * closing;
|
|
ay += perpY * side * 2.0 * closing;
|
|
bypass = true;
|
|
}
|
|
} else if (dist < SEPARATION * 1.2) {
|
|
const dz = alt - (other.alt ?? alt);
|
|
if (Math.abs(dz) < ALT_SEP) {
|
|
altAccel += (dz >= 0 ? 1 : -1) * 2.5;
|
|
}
|
|
}
|
|
}
|
|
return { ax, ay, altAccel, metrics: { passOver, bypass } };
|
|
}
|
|
|
|
function enforceMinProgress(
|
|
d: Drone,
|
|
n: number,
|
|
ax: number,
|
|
ay: number,
|
|
vx: number,
|
|
vy: number,
|
|
): { ax: number; ay: number } {
|
|
return resolveSteerStall(d, n, ax, ay, vx, vy);
|
|
}
|
|
|
|
function accumulateFlightStats(
|
|
acc: FlightAccumulator,
|
|
d: Drone,
|
|
prev: Drone | undefined,
|
|
metrics: SteerMetrics,
|
|
): void {
|
|
const speed = Math.hypot(d.vel.x, d.vel.y);
|
|
acc.speedSum += speed;
|
|
acc.samples += 1;
|
|
if (speed < IDLE_SPEED) acc.idleSamples += 1;
|
|
if (prev) {
|
|
const dHeading = Math.atan2(d.vel.y, d.vel.x);
|
|
const pHeading = Math.atan2(prev.vel.y, prev.vel.x);
|
|
let dh = Math.abs(dHeading - pHeading);
|
|
if (dh > Math.PI) dh = 2 * Math.PI - dh;
|
|
const disp = Math.hypot(d.pos.x - prev.pos.x, d.pos.y - prev.pos.y);
|
|
if (speed < SPIN_SPEED && dh > 0.8 && disp < 4) acc.spinSamples += 1;
|
|
}
|
|
if (metrics.passOver) acc.passOverSamples += 1;
|
|
if (metrics.bypass) acc.bypassSamples += 1;
|
|
}
|
|
|
|
// Transit objects enter at one edge, cross the whole area, and respawn at
|
|
// a fresh edge once they leave — a stream of through-traffic instead of
|
|
// obstacles bouncing until they wedge into a corner.
|
|
const TRANSIT_MARGIN = 120;
|
|
const TRANSIT_ALT_MIN = 16;
|
|
const TRANSIT_ALT_MAX = 58;
|
|
const TRANSIT_ALT_CRUISE = 28;
|
|
const HOUSE_ROOF_ALT = 24;
|
|
export interface RoadSegment {
|
|
ax: number;
|
|
ay: number;
|
|
bx: number;
|
|
by: number;
|
|
}
|
|
|
|
function roadKey(i: number, j: number): string {
|
|
return i < j ? `${i}-${j}` : `${j}-${i}`;
|
|
}
|
|
|
|
/** Tron-style road vectors between static houses: MST plus two nearest links per house. */
|
|
export function houseRoadNetwork(obstacles: Obstacle[]): RoadSegment[] {
|
|
const houses = obstacles.filter((o) => !o.moving);
|
|
const n = houses.length;
|
|
if (n < 2) return [];
|
|
|
|
const segs = new Map<string, RoadSegment>();
|
|
const add = (i: number, j: number): void => {
|
|
if (i === j) return;
|
|
const key = roadKey(i, j);
|
|
if (segs.has(key)) return;
|
|
segs.set(key, {
|
|
ax: houses[i].pos.x,
|
|
ay: houses[i].pos.y,
|
|
bx: houses[j].pos.x,
|
|
by: houses[j].pos.y,
|
|
});
|
|
};
|
|
|
|
const inMst = new Array(n).fill(false);
|
|
inMst[0] = true;
|
|
for (let added = 1; added < n; added++) {
|
|
let bestFrom = 0;
|
|
let bestTo = 0;
|
|
let bestDist = Infinity;
|
|
for (let i = 0; i < n; i++) {
|
|
if (!inMst[i]) continue;
|
|
for (let j = 0; j < n; j++) {
|
|
if (inMst[j]) continue;
|
|
const d = Math.hypot(houses[i].pos.x - houses[j].pos.x, houses[i].pos.y - houses[j].pos.y);
|
|
if (d < bestDist) {
|
|
bestDist = d;
|
|
bestFrom = i;
|
|
bestTo = j;
|
|
}
|
|
}
|
|
}
|
|
add(bestFrom, bestTo);
|
|
inMst[bestTo] = true;
|
|
}
|
|
|
|
for (let i = 0; i < n; i++) {
|
|
const nearest = houses
|
|
.map((h, j) => ({
|
|
j,
|
|
d: i === j ? Infinity : Math.hypot(h.pos.x - houses[i].pos.x, h.pos.y - houses[i].pos.y),
|
|
}))
|
|
.sort((a, b) => a.d - b.d);
|
|
add(i, nearest[0].j);
|
|
add(i, nearest[1].j);
|
|
}
|
|
|
|
return [...segs.values()];
|
|
}
|
|
|
|
function spawnStaticHouse(id: number, x: number, y: number, radius: number): Obstacle {
|
|
return { id, pos: { x, y }, radius, moving: false, vel: { x: 0, y: 0 } };
|
|
}
|
|
|
|
function staticHouses(): Obstacle[] {
|
|
const slots = [
|
|
{ xf: 0.1, y: 95, radius: 36 },
|
|
{ xf: 0.1, y: 280, radius: 40 },
|
|
{ xf: 0.1, y: 470, radius: 38 },
|
|
{ xf: 0.1, y: 660, radius: 42 },
|
|
{ xf: 0.1, y: 850, radius: 36 },
|
|
{ xf: 0.24, y: 160, radius: 44 },
|
|
{ xf: 0.24, y: 390, radius: 46 },
|
|
{ xf: 0.24, y: 610, radius: 40 },
|
|
{ xf: 0.24, y: 820, radius: 48 },
|
|
{ xf: 0.38, y: 110, radius: 42 },
|
|
{ xf: 0.38, y: 330, radius: 38 },
|
|
{ xf: 0.38, y: 520, radius: 50 },
|
|
{ xf: 0.38, y: 730, radius: 44 },
|
|
{ xf: 0.38, y: 920, radius: 40 },
|
|
{ xf: 0.52, y: 200, radius: 46 },
|
|
{ xf: 0.52, y: 440, radius: 42 },
|
|
{ xf: 0.52, y: 680, radius: 48 },
|
|
{ xf: 0.66, y: 130, radius: 40 },
|
|
{ xf: 0.66, y: 360, radius: 44 },
|
|
{ xf: 0.66, y: 560, radius: 46 },
|
|
{ xf: 0.66, y: 780, radius: 42 },
|
|
{ xf: 0.66, y: 940, radius: 38 },
|
|
{ xf: 0.8, y: 240, radius: 48 },
|
|
{ xf: 0.8, y: 480, radius: 40 },
|
|
{ xf: 0.8, y: 720, radius: 44 },
|
|
{ xf: 0.92, y: 160, radius: 42 },
|
|
{ xf: 0.92, y: 420, radius: 46 },
|
|
{ xf: 0.92, y: 640, radius: 38 },
|
|
{ xf: 0.92, y: 880, radius: 44 },
|
|
];
|
|
return slots.map((s, id) => spawnStaticHouse(id, areaX * s.xf, s.y, s.radius));
|
|
}
|
|
|
|
function transitEdgePoint(edge: number): Vec {
|
|
const inset = 90;
|
|
const pad = TRANSIT_MARGIN;
|
|
switch (edge) {
|
|
case 0:
|
|
return { x: inset + rnd() * (areaX - 2 * inset), y: -pad + 20 };
|
|
case 1:
|
|
return { x: inset + rnd() * (areaX - 2 * inset), y: AREA + pad - 20 };
|
|
case 2:
|
|
return { x: -pad + 20, y: inset + rnd() * (AREA - 2 * inset) };
|
|
default:
|
|
return { x: areaX + pad - 20, y: inset + rnd() * (AREA - 2 * inset) };
|
|
}
|
|
}
|
|
|
|
function spawnTransit(id: number): Obstacle {
|
|
const cruise = 28 + rnd() * 28;
|
|
const entryEdge = Math.floor(rnd() * 4);
|
|
const exitEdge =
|
|
rnd() < 0.72
|
|
? (entryEdge + 2) % 4
|
|
: (entryEdge + 2 + (rnd() < 0.5 ? 1 : 3)) % 4;
|
|
const entry = transitEdgePoint(entryEdge);
|
|
const exit = transitEdgePoint(exitEdge);
|
|
const dx = exit.x - entry.x;
|
|
const dy = exit.y - entry.y;
|
|
const dist = Math.hypot(dx, dy) || 1;
|
|
return {
|
|
id,
|
|
pos: entry,
|
|
radius: 22 + rnd() * 16,
|
|
moving: true,
|
|
vel: { x: (dx / dist) * cruise, y: (dy / dist) * cruise },
|
|
exit,
|
|
alt: TRANSIT_ALT_CRUISE + rnd() * 6,
|
|
altVel: 0,
|
|
transitCruise: cruise,
|
|
};
|
|
}
|
|
function outOfTransit(ob: Obstacle): boolean {
|
|
return (
|
|
ob.pos.x < -TRANSIT_MARGIN ||
|
|
ob.pos.x > areaX + TRANSIT_MARGIN ||
|
|
ob.pos.y < -TRANSIT_MARGIN ||
|
|
ob.pos.y > AREA + TRANSIT_MARGIN
|
|
);
|
|
}
|
|
|
|
function steerTransit(
|
|
transit: Obstacle,
|
|
obstacles: Obstacle[],
|
|
drones: Drone[],
|
|
dt: number,
|
|
): Obstacle {
|
|
let { x, y } = transit.pos;
|
|
let vx = transit.vel.x;
|
|
let vy = transit.vel.y;
|
|
let alt = transit.alt ?? TRANSIT_ALT_CRUISE;
|
|
let altVel = transit.altVel ?? 0;
|
|
const baseCruise = transit.transitCruise ?? (Math.hypot(vx, vy) || 28);
|
|
|
|
let ax = 0;
|
|
let ay = 0;
|
|
let altAccel = 0;
|
|
|
|
if (transit.exit) {
|
|
const dx = transit.exit.x - x;
|
|
const dy = transit.exit.y - y;
|
|
const dist = Math.hypot(dx, dy) || 1;
|
|
ax += (dx / dist) * 3.2;
|
|
ay += (dy / dist) * 3.2;
|
|
} else {
|
|
ax += (vx / baseCruise) * 2;
|
|
ay += (vy / baseCruise) * 2;
|
|
}
|
|
|
|
for (const house of obstacles) {
|
|
if (house.moving) continue;
|
|
const dx = x - house.pos.x;
|
|
const dy = y - house.pos.y;
|
|
const dist = Math.hypot(dx, dy);
|
|
const threat = house.radius + transit.radius + 48;
|
|
if (dist > threat + 130 || dist < 0.01) continue;
|
|
|
|
const nx = dx / dist;
|
|
const ny = dy / dist;
|
|
const closing = -(vx * nx + vy * ny);
|
|
if (closing < 0.08) continue;
|
|
|
|
const urgency = 1 + Math.min(2.2, closing / baseCruise);
|
|
const preferOver =
|
|
(transit.id + house.id) % 3 !== 1 && alt < TRANSIT_ALT_MAX - 10;
|
|
const flyingOver = alt >= HOUSE_ROOF_ALT + 10;
|
|
|
|
if (preferOver && !flyingOver) {
|
|
altAccel += 7.5 * urgency * closing;
|
|
ax += nx * 0.35 * urgency;
|
|
ay += ny * 0.35 * urgency;
|
|
} else if (!flyingOver) {
|
|
if (dist < threat) {
|
|
const push = ((threat - dist) / threat) * 4.2 * urgency;
|
|
ax += nx * push;
|
|
ay += ny * push;
|
|
}
|
|
const tx = -ny;
|
|
const ty = nx;
|
|
const side = vx * ty - vy * tx >= 0 ? 1 : -1;
|
|
ax += tx * side * 2.4 * urgency;
|
|
ay += ty * side * 2.4 * urgency;
|
|
}
|
|
}
|
|
|
|
// Drones: throttle only — no evasive steering or altitude changes.
|
|
let speedFactor = 1;
|
|
const dirX = vx / baseCruise;
|
|
const dirY = vy / baseCruise;
|
|
let droneAhead = false;
|
|
for (const d of drones) {
|
|
const dx = x - d.pos.x;
|
|
const dy = y - d.pos.y;
|
|
const dist = Math.hypot(dx, dy);
|
|
if (dist > 160 || dist < 0.01) continue;
|
|
const nx = dx / dist;
|
|
const ny = dy / dist;
|
|
const ahead = dirX * (-nx) + dirY * (-ny);
|
|
if (ahead > 0.2 && dist < 130) {
|
|
droneAhead = true;
|
|
speedFactor = Math.min(speedFactor, 0.48 + (dist / 130) * 0.42);
|
|
} else if (dist < 70) {
|
|
speedFactor = Math.min(speedFactor, 0.72);
|
|
}
|
|
}
|
|
if (!droneAhead) {
|
|
speedFactor = Math.min(1.16, speedFactor + 0.06);
|
|
}
|
|
speedFactor = Math.max(0.42, Math.min(1.16, speedFactor));
|
|
const targetCruise = baseCruise * speedFactor;
|
|
|
|
const mag = Math.hypot(ax, ay) || 1;
|
|
vx = vx * 0.82 + (ax / mag) * targetCruise * 0.18;
|
|
vy = vy * 0.82 + (ay / mag) * targetCruise * 0.18;
|
|
const spd = Math.hypot(vx, vy) || targetCruise;
|
|
vx = (vx / spd) * targetCruise;
|
|
vy = (vy / spd) * targetCruise;
|
|
|
|
const cruiseAlt = TRANSIT_ALT_CRUISE + Math.sin((transit.id + 1) * 1.7) * 2.5;
|
|
altAccel += (cruiseAlt - alt) * 0.5;
|
|
altVel = altVel * 0.88 + altAccel * dt * 12;
|
|
alt = Math.max(TRANSIT_ALT_MIN, Math.min(TRANSIT_ALT_MAX, alt + altVel * dt));
|
|
|
|
x += vx * dt;
|
|
y += vy * dt;
|
|
|
|
for (const house of obstacles) {
|
|
if (house.moving) continue;
|
|
if (alt >= HOUSE_ROOF_ALT + 8) continue;
|
|
const dx = x - house.pos.x;
|
|
const dy = y - house.pos.y;
|
|
const dist = Math.hypot(dx, dy);
|
|
const minD = house.radius + transit.radius + 44;
|
|
if (dist < minD && dist > 0.01) {
|
|
const nx = dx / dist;
|
|
const ny = dy / dist;
|
|
x = house.pos.x + nx * minD;
|
|
y = house.pos.y + ny * minD;
|
|
const dot = vx * nx + vy * ny;
|
|
if (dot < 0) {
|
|
vx -= dot * nx * 1.1;
|
|
vy -= dot * ny * 1.1;
|
|
const s = Math.hypot(vx, vy) || targetCruise;
|
|
vx = (vx / s) * targetCruise;
|
|
vy = (vy / s) * targetCruise;
|
|
}
|
|
}
|
|
}
|
|
|
|
return { ...transit, pos: { x, y }, vel: { x: vx, y: vy }, alt, altVel };
|
|
}
|
|
|
|
export function makeWorld(
|
|
droneCount: number,
|
|
aspect = 1,
|
|
routeMode: RouteMode = "default",
|
|
): World {
|
|
areaX = AREA * Math.max(1, Math.min(MAX_ASPECT, aspect));
|
|
const padPos = basePadCenter();
|
|
const basePad: BasePad = {
|
|
pos: padPos,
|
|
radius: BASE_PAD_RADIUS,
|
|
totalOffloaded: 0,
|
|
offloadingIds: [],
|
|
};
|
|
const drones: Drone[] = Array.from({ length: droneCount }, (_, i) => {
|
|
const onPerimeter = routeMode === "perimeter";
|
|
const angle = (i / Math.max(1, droneCount)) * Math.PI * 2;
|
|
const ring = 6 + rnd() * 8;
|
|
const spawn = onPerimeter
|
|
? {
|
|
pos: {
|
|
x: padPos.x + Math.cos(angle) * ring,
|
|
y: padPos.y + Math.sin(angle) * ring,
|
|
},
|
|
heading: angle,
|
|
}
|
|
: {
|
|
pos: {
|
|
x: areaX / 2 + Math.cos((i / droneCount) * Math.PI * 2) * (150 + rnd() * 120),
|
|
y: AREA / 2 + Math.sin((i / droneCount) * Math.PI * 2) * (150 + rnd() * 120),
|
|
},
|
|
heading: (i / droneCount) * Math.PI * 2,
|
|
};
|
|
const base: Drone = {
|
|
id: i,
|
|
pos: spawn.pos,
|
|
vel: { x: 0, y: 0 },
|
|
heading: spawn.heading,
|
|
waypoint: i % 4,
|
|
battery: 94 + rnd() * 6,
|
|
channel: CHANNELS[i % CHANNELS.length],
|
|
};
|
|
if (onPerimeter) {
|
|
base.alt = 1.2 + rnd() * 0.8;
|
|
base.altVel = 0;
|
|
base.aware = new Map();
|
|
base.phase = "deploy";
|
|
base.dataBuffer = rnd() * 80_000;
|
|
}
|
|
return base;
|
|
});
|
|
// Static obstacles sit at fixed fractions of the field, so they spread
|
|
// out instead of clustering left when the field widens
|
|
const houses = staticHouses();
|
|
const obstacles: Obstacle[] = [
|
|
...houses,
|
|
spawnTransit(houses.length),
|
|
spawnTransit(houses.length + 1),
|
|
spawnTransit(houses.length + 2),
|
|
];
|
|
return {
|
|
t: 0,
|
|
drones,
|
|
obstacles,
|
|
links: new Map(),
|
|
totalBytes: 0,
|
|
broadcasts: 0,
|
|
routeMode,
|
|
swarmAlgo: "boids",
|
|
perimeterHeat: Array.from({ length: PERIMETER_SEGMENTS }, () => 0),
|
|
hyphaStrength: Array.from({ length: PERIMETER_SEGMENTS }, () => 0),
|
|
flightAcc: emptyFlightAcc(),
|
|
statsWindowStart: 0,
|
|
flightStats: flightAccToStats(emptyFlightAcc()),
|
|
basePad,
|
|
landingQueue: [],
|
|
};
|
|
}
|
|
|
|
function linkKey(a: number, b: number): string {
|
|
return a < b ? `${a}-${b}` : `${b}-${a}`;
|
|
}
|
|
|
|
function linkRange3d(a: Drone, b: Drone): number {
|
|
const dx = a.pos.x - b.pos.x;
|
|
const dy = a.pos.y - b.pos.y;
|
|
const dz = (a.alt ?? 0) - (b.alt ?? 0);
|
|
return Math.hypot(dx, dy, dz);
|
|
}
|
|
|
|
function cloneAware(m?: Map<number, TransitIntel>): Map<number, TransitIntel> {
|
|
return new Map(m ?? []);
|
|
}
|
|
|
|
function intelFrom(ob: Obstacle, t: number): TransitIntel {
|
|
return {
|
|
id: ob.id,
|
|
pos: { x: ob.pos.x, y: ob.pos.y },
|
|
vel: { x: ob.vel.x, y: ob.vel.y },
|
|
radius: ob.radius,
|
|
seenAt: t,
|
|
};
|
|
}
|
|
|
|
function observeDrone(d: Drone, obstacles: Obstacle[], t: number): Drone {
|
|
if (!d.aware) return d;
|
|
const aware = cloneAware(d.aware);
|
|
for (const ob of obstacles) {
|
|
if (!ob.moving) continue;
|
|
if (outOfTransit(ob)) {
|
|
aware.delete(ob.id);
|
|
continue;
|
|
}
|
|
if (aware.has(ob.id)) {
|
|
aware.set(ob.id, intelFrom(ob, t));
|
|
continue;
|
|
}
|
|
const dist = Math.hypot(d.pos.x - ob.pos.x, d.pos.y - ob.pos.y);
|
|
if (dist <= DETECT_RANGE) aware.set(ob.id, intelFrom(ob, t));
|
|
}
|
|
return { ...d, aware };
|
|
}
|
|
|
|
function mergeIntel(into: Map<number, TransitIntel>, from: Map<number, TransitIntel>): boolean {
|
|
let added = false;
|
|
for (const [id, intel] of from) {
|
|
const prev = into.get(id);
|
|
if (!prev) added = true;
|
|
if (!prev || intel.seenAt >= prev.seenAt) into.set(id, { ...intel });
|
|
}
|
|
return added;
|
|
}
|
|
|
|
function propagateIntel(
|
|
drones: Drone[],
|
|
): { drones: Drone[]; intelLinks: Set<string> } {
|
|
const awareList = drones.map((d) => cloneAware(d.aware));
|
|
const intelLinks = new Set<string>();
|
|
|
|
for (let i = 0; i < drones.length; i++) {
|
|
for (let j = i + 1; j < drones.length; j++) {
|
|
if (linkRange3d(drones[i], drones[j]) > LINK_RANGE) continue;
|
|
const ai = awareList[i].size;
|
|
const aj = awareList[j].size;
|
|
mergeIntel(awareList[i], awareList[j]);
|
|
mergeIntel(awareList[j], awareList[i]);
|
|
if (awareList[i].size > ai || awareList[j].size > aj) {
|
|
intelLinks.add(linkKey(i, j));
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
drones: drones.map((d, i) => ({ ...d, aware: awareList[i] })),
|
|
intelLinks,
|
|
};
|
|
}
|
|
|
|
function escortSlot(slotIndex: number, intel: TransitIntel, t: number): { x: number; y: number; alt: number } {
|
|
const golden = 2.399963229;
|
|
const spin = t * 0.2;
|
|
const phi = slotIndex * golden + spin + intel.id * 0.45;
|
|
const u = slotIndex * 0.22 + 0.12;
|
|
const theta = Math.acos(1 - 2 * Math.min(0.92, u));
|
|
const shell = intel.radius + 72;
|
|
const sinT = Math.sin(theta);
|
|
return {
|
|
x: intel.pos.x + Math.cos(phi) * sinT * shell,
|
|
y: intel.pos.y + Math.sin(phi) * sinT * shell,
|
|
alt: ALT_MIN + 12 + (1 - Math.cos(theta)) * 0.55 * (ALT_MAX - ALT_MIN) + slotIndex * 2.5,
|
|
};
|
|
}
|
|
|
|
function applyEscortSteer(
|
|
slotIndex: number,
|
|
intel: TransitIntel,
|
|
d: Drone,
|
|
t: number,
|
|
ax: number,
|
|
ay: number,
|
|
alt: number,
|
|
altAccel: number,
|
|
): { ax: number; ay: number; altAccel: number } {
|
|
const toObjX = intel.pos.x - d.pos.x;
|
|
const toObjY = intel.pos.y - d.pos.y;
|
|
const distToObj = Math.hypot(toObjX, toObjY);
|
|
if (distToObj < 0.01) return { ax, ay, altAccel };
|
|
|
|
const slot = escortSlot(slotIndex, intel, t);
|
|
const toSlotX = slot.x - d.pos.x;
|
|
const toSlotY = slot.y - d.pos.y;
|
|
const distToSlot = Math.hypot(toSlotX, toSlotY);
|
|
|
|
let sx = toSlotX / (distToSlot || 1);
|
|
let sy = toSlotY / (distToSlot || 1);
|
|
let escortW = 0.88;
|
|
|
|
// Far — intercept object center (mesh drones heading in)
|
|
if (distToObj > intel.radius + 200) {
|
|
sx = toObjX / distToObj;
|
|
sy = toObjY / distToObj;
|
|
escortW = 0.94;
|
|
} else if (distToObj > intel.radius + 110) {
|
|
sx = sx * 0.45 + (toObjX / distToObj) * 0.55;
|
|
sy = sy * 0.45 + (toObjY / distToObj) * 0.55;
|
|
escortW = 0.9;
|
|
}
|
|
|
|
const altPull = (slot.alt - alt) * 2.8 * escortW;
|
|
return {
|
|
ax: ax * (1 - escortW) + sx * 3.1 * escortW,
|
|
ay: ay * (1 - escortW) + sy * 3.1 * escortW,
|
|
altAccel: altAccel + altPull,
|
|
};
|
|
}
|
|
|
|
export interface EscortDuty {
|
|
transitId: number;
|
|
slot: number;
|
|
intel: TransitIntel;
|
|
}
|
|
|
|
export interface SteerContext extends World {
|
|
escortDuty: Map<number, EscortDuty>;
|
|
steerMetrics?: Map<number, SteerMetrics>;
|
|
}
|
|
|
|
function buildEscortDuty(drones: Drone[], obstacles: Obstacle[], t: number): Map<number, EscortDuty> {
|
|
const duty = new Map<number, EscortDuty>();
|
|
const used = new Set<number>();
|
|
|
|
for (const ob of onFieldTransits(obstacles)) {
|
|
if (!drones.some((d) => d.aware?.has(ob.id))) continue;
|
|
|
|
const intel = intelFrom(ob, t);
|
|
const sorted = drones
|
|
.filter((d) => !used.has(d.id) && (d.phase === "patrol" || !d.phase))
|
|
.map((d) => ({
|
|
d,
|
|
dist: Math.hypot(d.pos.x - ob.pos.x, d.pos.y - ob.pos.y),
|
|
}))
|
|
.sort((a, b) => a.dist - b.dist);
|
|
|
|
let slot = 0;
|
|
for (const { d } of sorted) {
|
|
if (slot >= ESCORT_PER_TRANSIT) break;
|
|
duty.set(d.id, { transitId: ob.id, slot, intel });
|
|
used.add(d.id);
|
|
slot += 1;
|
|
}
|
|
}
|
|
return duty;
|
|
}
|
|
|
|
export function droneDetectsTransit(d: Drone, ob: Obstacle): boolean {
|
|
if (!ob.moving || outOfTransit(ob)) return false;
|
|
return Math.hypot(d.pos.x - ob.pos.x, d.pos.y - ob.pos.y) <= DETECT_RANGE;
|
|
}
|
|
|
|
function steer(d: Drone, world: SteerContext, dt: number, obstacles: Obstacle[]): Drone {
|
|
const route = routeOf(d, world.routeMode ?? "default");
|
|
const swarm = world.routeMode === "perimeter";
|
|
const pad = world.basePad;
|
|
const phase = d.phase ?? (swarm ? "patrol" : undefined);
|
|
const duty = world.escortDuty.get(d.id);
|
|
const escorting = Boolean(swarm && duty && phase === "patrol");
|
|
const patrolling = Boolean(swarm && phase === "patrol" && !escorting);
|
|
|
|
if (swarm && phase === "landed" && pad) {
|
|
const spot = padLandingSpot(d.id, pad);
|
|
const progress = Math.min(1, (d.offloadProgress ?? 0) + dt / OFFLOAD_PAD_SECONDS);
|
|
const buffer = d.dataBuffer ?? 0;
|
|
const offloaded = Math.min(buffer, OFFLOAD_RATE * dt);
|
|
const newBuffer = Math.max(0, buffer - offloaded);
|
|
const newBattery = Math.min(100, d.battery + BATTERY_RECHARGE * dt);
|
|
const ready =
|
|
progress >= 1 && newBuffer <= 0 && newBattery >= BATTERY_DEPLOY_MIN;
|
|
return {
|
|
...d,
|
|
phase: ready ? "takeoff" : "landed",
|
|
pos: spot,
|
|
vel: { x: 0, y: 0 },
|
|
heading: d.heading,
|
|
alt: 1.1,
|
|
altVel: ready ? 2.5 : 0,
|
|
offloadProgress: progress,
|
|
dataBuffer: newBuffer,
|
|
battery: newBattery,
|
|
};
|
|
}
|
|
|
|
const wp = route[(d.waypoint + d.id) % route.length];
|
|
let ax = 0;
|
|
let ay = 0;
|
|
let waypoint = d.waypoint;
|
|
let cruiseMul = 1;
|
|
|
|
if (swarm && pad && phase === "deploy") {
|
|
const target = slotTarget(d, world.drones.length);
|
|
const dx = target.x - d.pos.x;
|
|
const dy = target.y - d.pos.y;
|
|
const dist = Math.hypot(dx, dy) || 1;
|
|
ax = (dx / dist) * 3.6;
|
|
ay = (dy / dist) * 3.6;
|
|
cruiseMul = 1.38;
|
|
} else if (swarm && pad && phase === "return") {
|
|
const dx = pad.pos.x - d.pos.x;
|
|
const dy = pad.pos.y - d.pos.y;
|
|
const dist = Math.hypot(dx, dy) || 1;
|
|
ax = (dx / dist) * 3.4;
|
|
ay = (dy / dist) * 3.4;
|
|
cruiseMul = 1.05;
|
|
} else if (swarm && pad && phase === "takeoff") {
|
|
const dx = pad.pos.x - d.pos.x;
|
|
const dy = pad.pos.y - d.pos.y;
|
|
const dist = Math.hypot(dx, dy) || 1;
|
|
if (dist > pad.radius * 0.25) {
|
|
ax = (dx / dist) * 1.2;
|
|
ay = (dy / dist) * 1.2;
|
|
}
|
|
cruiseMul = 0.55;
|
|
} else if (patrolling) {
|
|
const algo = world.swarmAlgo ?? "boids";
|
|
switch (algo) {
|
|
case "perimeter":
|
|
({ ax, ay } = evenPerimeterSteer(d, world.drones.length, ax, ay));
|
|
({ ax, ay } = linkSeekSteer(d, world.drones, ax, ay));
|
|
break;
|
|
case "boids":
|
|
({ ax, ay } = patrolSteerBoids(d, world.drones, ax, ay));
|
|
break;
|
|
case "apf":
|
|
({ ax, ay } = patrolSteerApf(d, world.drones, obstacles, ax, ay));
|
|
break;
|
|
case "aco":
|
|
({ ax, ay } = patrolSteerAco(
|
|
d,
|
|
world.drones,
|
|
world.perimeterHeat ?? [],
|
|
ax,
|
|
ay,
|
|
));
|
|
break;
|
|
case "hypha":
|
|
({ ax, ay } = patrolSteerHypha(
|
|
d,
|
|
world.drones,
|
|
world.perimeterHeat ?? [],
|
|
world.hyphaStrength ?? [],
|
|
ax,
|
|
ay,
|
|
));
|
|
break;
|
|
case "frontier":
|
|
({ ax, ay } = patrolSteerFrontier(
|
|
d,
|
|
world.drones,
|
|
world.perimeterHeat ?? [],
|
|
world.hyphaStrength ?? [],
|
|
world.t,
|
|
ax,
|
|
ay,
|
|
));
|
|
break;
|
|
case "wave":
|
|
({ ax, ay } = patrolSteerWave(d, world.drones, world.t, ax, ay));
|
|
break;
|
|
case "mycelium":
|
|
({ ax, ay } = patrolSteerMycelium(
|
|
d,
|
|
world.drones,
|
|
world.perimeterHeat ?? [],
|
|
world.hyphaStrength ?? [],
|
|
world.t,
|
|
ax,
|
|
ay,
|
|
));
|
|
break;
|
|
}
|
|
} else {
|
|
ax = wp.x - d.pos.x;
|
|
ay = wp.y - d.pos.y;
|
|
const wpDist = Math.hypot(ax, ay);
|
|
if (wpDist < 90) waypoint = (d.waypoint + 1) % route.length;
|
|
ax /= wpDist || 1;
|
|
ay /= wpDist || 1;
|
|
}
|
|
|
|
let altAccel = 0;
|
|
let alt = d.alt ?? 22;
|
|
let altVel = d.altVel ?? 0;
|
|
let metrics: SteerMetrics = { passOver: false, bypass: false };
|
|
|
|
if (swarm && phase === "return") {
|
|
altAccel += (3.2 - alt) * 4.2;
|
|
} else if (swarm && phase === "takeoff") {
|
|
altAccel += (ALT_MIN + 12 - alt) * 5.5;
|
|
} else if (swarm && phase === "deploy") {
|
|
const band = ALT_MIN + (d.id % 6) * 4.5;
|
|
altAccel += (band - alt) * 3.2;
|
|
}
|
|
|
|
if (swarm && phase === "patrol") {
|
|
({ ax, ay, altAccel, metrics } = applyPeerDeconflict(d, world.drones, ax, ay, alt));
|
|
} else if (!swarm) {
|
|
for (const other of world.drones) {
|
|
if (other.id === d.id) continue;
|
|
const dx = d.pos.x - other.pos.x;
|
|
const dy = d.pos.y - other.pos.y;
|
|
const dist = Math.hypot(dx, dy);
|
|
if (dist < SEPARATION && dist > 0.01) {
|
|
const push = (SEPARATION - dist) / SEPARATION;
|
|
ax += (dx / dist) * push * 2.4;
|
|
ay += (dy / dist) * push * 2.4;
|
|
}
|
|
}
|
|
} else if (swarm && (phase === "deploy" || phase === "return")) {
|
|
for (const other of world.drones) {
|
|
if (other.id === d.id) continue;
|
|
const dx = d.pos.x - other.pos.x;
|
|
const dy = d.pos.y - other.pos.y;
|
|
const dist = Math.hypot(dx, dy);
|
|
if (dist < SEPARATION * 0.85 && dist > 0.01) {
|
|
const push = (SEPARATION * 0.85 - dist) / SEPARATION;
|
|
ax += (dx / dist) * push * 2;
|
|
ay += (dy / dist) * push * 2;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (duty && phase === "patrol") {
|
|
({ ax, ay, altAccel } = applyEscortSteer(duty.slot, duty.intel, d, world.t, ax, ay, alt, altAccel));
|
|
}
|
|
|
|
for (const ob of obstacles) {
|
|
if (ob.moving && duty?.transitId === ob.id) continue;
|
|
const dx = d.pos.x - ob.pos.x;
|
|
const dy = d.pos.y - ob.pos.y;
|
|
const dist = Math.hypot(dx, dy);
|
|
const margin = ob.radius + 55;
|
|
if (dist < margin && dist > 0.01) {
|
|
const push = (margin - dist) / margin;
|
|
ax += (dx / dist) * push * 4.0;
|
|
ay += (dy / dist) * push * 4.0;
|
|
}
|
|
}
|
|
|
|
if (patrolling) {
|
|
({ ax, ay } = enforceMinProgress(d, world.drones.length, ax, ay, d.vel.x, d.vel.y));
|
|
} else if (phase === "deploy") {
|
|
({ ax, ay } = resolveSteerStall(d, world.drones.length, ax, ay, d.vel.x, d.vel.y));
|
|
}
|
|
|
|
world.steerMetrics?.set(d.id, metrics);
|
|
|
|
const { vx, vy, heading } = integrateVel(d, ax, ay, cruiseMul, phase);
|
|
|
|
if (swarm && phase !== "landed") {
|
|
if (phase === "patrol" || escorting) {
|
|
const band = ALT_MIN + (d.id % 6) * 4.5;
|
|
const legWave = Math.sin(world.t * 0.62 + d.id * 0.91) * (escorting ? 2.5 : 3);
|
|
const posWave =
|
|
Math.sin(d.pos.x * 0.011 + world.t * 0.45) * (escorting ? 1.5 : 2) +
|
|
Math.cos(d.pos.y * 0.009 + world.t * 0.38 + d.id) * (escorting ? 1.5 : 2);
|
|
const beeBob = escorting
|
|
? Math.sin(world.t * 3.2 + d.id * 1.4) * 4
|
|
: Math.sin(world.t * 2.4 + d.id * 1.2) * 5;
|
|
const targetAlt = band + legWave + posWave + beeBob;
|
|
altAccel += (targetAlt - alt) * (escorting ? 1.4 : 1.6);
|
|
}
|
|
altVel = altVel * 0.8 + altAccel * dt * (phase === "return" ? 18 : 15);
|
|
alt = Math.max(0.8, Math.min(ALT_MAX, alt + altVel * dt));
|
|
}
|
|
|
|
let batteryDrain = 0.05;
|
|
if (phase === "patrol") batteryDrain = escorting ? 0.11 : 0.085;
|
|
else if (phase === "deploy" || phase === "return") batteryDrain = 0.07;
|
|
else if (phase === "takeoff") batteryDrain = 0.04;
|
|
|
|
let dataBuffer = d.dataBuffer ?? 0;
|
|
if (phase === "patrol") dataBuffer += DATA_COLLECT_RATE * dt;
|
|
|
|
let nextPhase = phase;
|
|
if (swarm && pad) {
|
|
if (phase === "deploy") {
|
|
const target = slotTarget(d, world.drones.length);
|
|
const distSlot = Math.hypot(d.pos.x - target.x, d.pos.y - target.y);
|
|
if (distSlot < 100 && alt >= ALT_MIN + 3) nextPhase = "patrol";
|
|
} else if (phase === "return") {
|
|
if (distToPad({ ...d, pos: { x: d.pos.x + vx * dt, y: d.pos.y + vy * dt } }, pad) < pad.radius * 0.82 && alt <= 4.8) {
|
|
nextPhase = "landed";
|
|
}
|
|
} else if (phase === "takeoff" && alt >= ALT_MIN + 8) {
|
|
nextPhase = "deploy";
|
|
}
|
|
}
|
|
|
|
const landedNow = nextPhase === "landed";
|
|
const spot = pad ? padLandingSpot(d.id, pad) : d.pos;
|
|
|
|
return {
|
|
...d,
|
|
phase: nextPhase,
|
|
waypoint,
|
|
vel: landedNow ? { x: 0, y: 0 } : { x: vx, y: vy },
|
|
heading,
|
|
pos: landedNow
|
|
? spot
|
|
: {
|
|
x: Math.max(20, Math.min(areaX - 20, d.pos.x + vx * dt)),
|
|
y: Math.max(20, Math.min(AREA - 20, d.pos.y + vy * dt)),
|
|
},
|
|
battery: Math.max(0, d.battery - dt * batteryDrain),
|
|
channel: rnd() < dt * 0.15 ? CHANNELS[Math.floor(rnd() * CHANNELS.length)] : d.channel,
|
|
dataBuffer,
|
|
offloadProgress: landedNow ? 0 : d.offloadProgress,
|
|
landingQueued: nextPhase === "return" ? false : d.landingQueued,
|
|
...(swarm ? { alt: landedNow ? 1.1 : alt, altVel: landedNow ? 0 : altVel } : {}),
|
|
};
|
|
}
|
|
|
|
export function tick(world: World, dt: number): World {
|
|
const obstacles = world.obstacles.map((ob) => {
|
|
if (!ob.moving) return ob;
|
|
const steered = steerTransit(ob, world.obstacles, world.drones, dt);
|
|
return outOfTransit(steered) ? spawnTransit(ob.id) : steered;
|
|
});
|
|
|
|
const observed = world.drones.map((d) => observeDrone(d, obstacles, world.t));
|
|
const { drones: informed, intelLinks } =
|
|
world.routeMode === "perimeter"
|
|
? propagateIntel(observed)
|
|
: { drones: observed, intelLinks: new Set<string>() };
|
|
|
|
const escortDuty = buildEscortDuty(informed, obstacles, world.t);
|
|
|
|
let landingQueue = world.landingQueue ?? [];
|
|
let pad = world.basePad;
|
|
let queueDrones = informed;
|
|
if (world.routeMode === "perimeter" && pad) {
|
|
const managed = manageLandingQueue(informed, pad, landingQueue);
|
|
queueDrones = managed.drones;
|
|
landingQueue = managed.landingQueue;
|
|
}
|
|
|
|
let flightAcc = world.flightAcc ?? emptyFlightAcc();
|
|
let statsWindowStart = world.statsWindowStart ?? world.t;
|
|
if (world.t - statsWindowStart > STATS_WINDOW_S) {
|
|
flightAcc = emptyFlightAcc();
|
|
statsWindowStart = world.t;
|
|
}
|
|
|
|
let perimeterHeat = world.perimeterHeat ?? Array.from({ length: PERIMETER_SEGMENTS }, () => 0);
|
|
let hyphaStrength = world.hyphaStrength ?? Array.from({ length: PERIMETER_SEGMENTS }, () => 0);
|
|
if (world.routeMode === "perimeter") {
|
|
perimeterHeat = updatePerimeterHeat(queueDrones, perimeterHeat, dt);
|
|
hyphaStrength = updateHyphaStrength(queueDrones, world.links, hyphaStrength, dt);
|
|
}
|
|
|
|
const steerMetrics = new Map<number, SteerMetrics>();
|
|
const prevDrones = new Map(queueDrones.map((d) => [d.id, d]));
|
|
|
|
const steerWorld: SteerContext = {
|
|
...world,
|
|
obstacles,
|
|
drones: queueDrones,
|
|
escortDuty,
|
|
perimeterHeat,
|
|
hyphaStrength,
|
|
steerMetrics,
|
|
basePad: pad,
|
|
landingQueue,
|
|
};
|
|
let drones = queueDrones.map((d) => steer(d, steerWorld, dt, obstacles));
|
|
|
|
let totalOffloaded = pad?.totalOffloaded ?? 0;
|
|
const offloadingIds: number[] = [];
|
|
if (pad) {
|
|
for (const d of drones) {
|
|
if (d.phase === "landed") {
|
|
offloadingIds.push(d.id);
|
|
const prev = prevDrones.get(d.id);
|
|
const prevBuf = prev?.dataBuffer ?? 0;
|
|
const nowBuf = d.dataBuffer ?? 0;
|
|
if (nowBuf < prevBuf) totalOffloaded += prevBuf - nowBuf;
|
|
}
|
|
}
|
|
pad = { ...pad, totalOffloaded, offloadingIds };
|
|
}
|
|
|
|
for (const d of drones) {
|
|
const metrics = steerMetrics.get(d.id) ?? { passOver: false, bypass: false };
|
|
accumulateFlightStats(flightAcc, d, prevDrones.get(d.id), metrics);
|
|
}
|
|
const flightStats = flightAccToStats(flightAcc);
|
|
|
|
const links = new Map(world.links);
|
|
let totalBytes = world.totalBytes;
|
|
let broadcasts = world.broadcasts;
|
|
|
|
// Decay all links; drop the ones out of range
|
|
for (const [key, l] of links) {
|
|
const a = drones[l.a];
|
|
const b = drones[l.b];
|
|
const inRange =
|
|
a &&
|
|
b &&
|
|
(world.routeMode === "perimeter"
|
|
? linkRange3d(a, b) <= LINK_RANGE
|
|
: Math.hypot(a.pos.x - b.pos.x, a.pos.y - b.pos.y) <= LINK_RANGE);
|
|
if (!inRange) {
|
|
links.delete(key);
|
|
continue;
|
|
}
|
|
links.set(key, { ...l, flash: Math.max(0, l.flash - dt * 3), rate: l.rate * 0.9 });
|
|
}
|
|
|
|
for (let i = 0; i < drones.length; i++) {
|
|
for (let j = i + 1; j < drones.length; j++) {
|
|
const dist =
|
|
world.routeMode === "perimeter"
|
|
? linkRange3d(drones[i], drones[j])
|
|
: Math.hypot(
|
|
drones[i].pos.x - drones[j].pos.x,
|
|
drones[i].pos.y - drones[j].pos.y,
|
|
);
|
|
if (dist > LINK_RANGE) continue;
|
|
const key = linkKey(i, j);
|
|
const link =
|
|
links.get(key) ??
|
|
({ key, a: i, b: j, totalUp: 0, totalDown: 0, rate: 0, flash: 0, bulk: 0 } as LinkStats);
|
|
|
|
// Pose broadcasts: 5 Hz each direction
|
|
const poseBytes = POSE_BYTES * POSE_HZ * dt;
|
|
let up = poseBytes;
|
|
let down = poseBytes;
|
|
if (rnd() < dt * POSE_HZ * 0.35) {
|
|
link.flash = 1;
|
|
broadcasts += 1;
|
|
}
|
|
if (intelLinks.has(key)) {
|
|
link.flash = Math.max(link.flash, 1);
|
|
broadcasts += 1;
|
|
}
|
|
// Occasional bulk sync (sealed partition replication)
|
|
if (link.bulk <= 0 && rnd() < dt * 0.02) {
|
|
link.bulk = 50_000 + rnd() * 450_000;
|
|
}
|
|
if (link.bulk > 0) {
|
|
const chunk = Math.min(link.bulk, 120_000 * dt);
|
|
link.bulk -= chunk;
|
|
if (rnd() < 0.5) up += chunk;
|
|
else down += chunk;
|
|
}
|
|
link.totalUp += up;
|
|
link.totalDown += down;
|
|
link.rate = link.rate * 0.9 + ((up + down) / dt) * 0.1;
|
|
totalBytes += up + down;
|
|
links.set(key, { ...link });
|
|
}
|
|
}
|
|
|
|
return {
|
|
t: world.t + dt,
|
|
drones,
|
|
obstacles,
|
|
links,
|
|
totalBytes,
|
|
broadcasts,
|
|
routeMode: world.routeMode,
|
|
swarmAlgo: world.swarmAlgo,
|
|
perimeterHeat,
|
|
hyphaStrength,
|
|
flightAcc,
|
|
statsWindowStart,
|
|
flightStats,
|
|
basePad: pad,
|
|
landingQueue,
|
|
};
|
|
}
|
|
|
|
export function transitKnownByFleet(world: World, transitId: number): boolean {
|
|
return world.drones.some((d) => d.aware?.has(transitId));
|
|
}
|
|
|
|
export function fmtBytes(n: number): string {
|
|
if (n < 1024) return `${n.toFixed(0)} B`;
|
|
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
|
if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`;
|
|
return `${(n / 1024 / 1024 / 1024).toFixed(2)} GB`;
|
|
}
|