From 1e5854ee0a64579399d6c3870cdd19c6cc323501 Mon Sep 17 00:00:00 2001 From: Andriy Oblivantsev Date: Sun, 12 Jul 2026 12:51:25 +0100 Subject: [PATCH] feat: prototype perimeter patrol, 3D view, and swarm algorithms Add optional Three.js scene, base-pad lifecycle, mycelial swarms, UFO transit steering, and algorithm selector (boids, APF, ACO, hypha). --- prototype/README.md | 3 + prototype/package-lock.json | 67 +- prototype/package.json | 4 +- prototype/src/App.tsx | 834 ++++++++++++++-- prototype/src/SwarmScene.ts | 1434 +++++++++++++++++++++++++++ prototype/src/SwarmView3D.tsx | 77 ++ prototype/src/live.ts | 32 +- prototype/src/search.ts | 50 + prototype/src/sim.ts | 1755 +++++++++++++++++++++++++++++++-- prototype/vite.config.ts | 20 + 10 files changed, 4084 insertions(+), 192 deletions(-) create mode 100644 prototype/src/SwarmScene.ts create mode 100644 prototype/src/SwarmView3D.tsx create mode 100644 prototype/src/search.ts diff --git a/prototype/README.md b/prototype/README.md index dae6e30..7da759e 100644 --- a/prototype/README.md +++ b/prototype/README.md @@ -2,6 +2,8 @@ A 2D top-down view of the swarm data plane from [04 — Swarm sync](../docs/04-swarm-sync.md): drones patrol an area with static and mobile obstacles, exchange 5 Hz pose broadcasts (blue link flashes), and run opportunistic bulk sync of sealed partitions (green links with live rate and cumulative up/down counters). +Optional **3d** checkbox in the header switches to a Three.js perimeter patrol scene (WebGPU/WebGL, shader terrain, transit escort, search panel). Off by default. + No backend — a pure client-side simulation of the same behavioral model the Python simulator implements over real Parquet and UDP. ## Run @@ -17,3 +19,4 @@ npm run dev # http://localhost:5173 - **Busy links turn green** and show current rate plus total transferred (`↑` / `↓`) — the bandwidth budget from the sync design made visible. - **Per-drone label**: id, current Wi-Fi channel (hopping), battery. - Controls: drone count, simulation speed, pause. +- **3d** — perimeter patrol, altitude, detection beams, 4-drone escort sphere diff --git a/prototype/package-lock.json b/prototype/package-lock.json index 8ed37c5..8b4d84b 100644 --- a/prototype/package-lock.json +++ b/prototype/package-lock.json @@ -9,11 +9,13 @@ "version": "0.1.0", "dependencies": { "react": "^18.3.1", - "react-dom": "^18.3.1" + "react-dom": "^18.3.1", + "three": "^0.172.0" }, "devDependencies": { "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", + "@types/three": "^0.172.0", "@vitejs/plugin-react": "^4.3.4", "typescript": "^5.6.3", "vite": "^6.0.3" @@ -1150,6 +1152,13 @@ "win32" ] }, + "node_modules/@tweenjs/tween.js": { + "version": "23.1.3", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", + "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1230,6 +1239,35 @@ "@types/react": "^18.0.0" } }, + "node_modules/@types/stats.js": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", + "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/three": { + "version": "0.172.0", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.172.0.tgz", + "integrity": "sha512-LrUtP3FEG26Zg5WiF0nbg8VoXiKokBLTcqM2iLvM9vzcfEiYmmBAPGdBgV0OYx9fvWlY3R/3ERTZcD9X5sc0NA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tweenjs/tween.js": "~23.1.3", + "@types/stats.js": "*", + "@types/webxr": "*", + "@webgpu/types": "*", + "fflate": "~0.8.2", + "meshoptimizer": "~0.18.1" + } + }, + "node_modules/@types/webxr": { + "version": "0.5.24", + "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", + "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", + "dev": true, + "license": "MIT" + }, "node_modules/@vitejs/plugin-react": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", @@ -1251,6 +1289,13 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@webgpu/types": { + "version": "0.1.71", + "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.71.tgz", + "integrity": "sha512-mMy8/ODcKhab808co15eW+yN+HgXoQxRQHTiBV9Mrvl1r0ufnid7YOcI+gi4eUWSWl9ezD6TW2KXccrL8HCh2A==", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/baseline-browser-mapping": { "version": "2.10.42", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.42.tgz", @@ -1428,6 +1473,13 @@ } } }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "dev": true, + "license": "MIT" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1507,6 +1559,13 @@ "yallist": "^3.0.2" } }, + "node_modules/meshoptimizer": { + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.18.1.tgz", + "integrity": "sha512-ZhoIoL7TNV4s5B6+rx5mC//fw8/POGyNxS/DZyCJeiZ12ScLfVwRE/GfsxwiTkMYYD5DmK2/JXnEVXqL4rF+Sw==", + "dev": true, + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -1701,6 +1760,12 @@ "node": ">=0.10.0" } }, + "node_modules/three": { + "version": "0.172.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.172.0.tgz", + "integrity": "sha512-6HMgMlzU97MsV7D/tY8Va38b83kz8YJX+BefKjspMNAv0Vx6dxMogHOrnRl/sbMIs3BPUKijPqDqJ/+UwJbIow==", + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", diff --git a/prototype/package.json b/prototype/package.json index aabc724..bbca356 100644 --- a/prototype/package.json +++ b/prototype/package.json @@ -10,11 +10,13 @@ }, "dependencies": { "react": "^18.3.1", - "react-dom": "^18.3.1" + "react-dom": "^18.3.1", + "three": "^0.172.0" }, "devDependencies": { "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", + "@types/three": "^0.172.0", "@vitejs/plugin-react": "^4.3.4", "typescript": "^5.6.3", "vite": "^6.0.3" diff --git a/prototype/src/App.tsx b/prototype/src/App.tsx index 9e0c2a6..ef94aa9 100644 --- a/prototype/src/App.tsx +++ b/prototype/src/App.tsx @@ -1,23 +1,28 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { DEFAULT_EXPLORER_URL, fetchLivePoses, toLiveWorld } from "./live"; +import { searchWeb, type SearchHit } from "./search"; import { AREA, MAX_ASPECT, + SWARM_ALGORITHM_LABELS, areaWidth, fmtBytes, + houseRoadNetwork, makeWorld, seed, tick, + type RouteMode, + type SwarmAlgorithm, type World, } from "./sim"; +import { SwarmView3D } from "./SwarmView3D"; +import type { RenderBackend } from "./SwarmScene"; const VIEW = 1000; const PANEL_W = 320; const HEADER_H = 70; +const LIVE_POLL_MS = 2000; -// Patrol field aspect ratio for the current window: wide monitors get a -// wider field instead of empty side margins. Quantized to 0.25 steps so -// minor resizes don't rebuild the world. function displayAspect(): number { const w = Math.max(320, window.innerWidth - PANEL_W); const h = Math.max(320, window.innerHeight - HEADER_H); @@ -25,27 +30,57 @@ function displayAspect(): number { return Math.max(1, Math.min(MAX_ASPECT, q)); } -const LIVE_POLL_MS = 2000; +function routeModeFor(_view3d: boolean): RouteMode { + return "perimeter"; +} export default function App(): JSX.Element { + const [view3d, setView3d] = useState(false); const [droneCount, setDroneCount] = useState(8); + const [swarmAlgo, setSwarmAlgo] = useState("boids"); const [speedup, setSpeedup] = useState(1); const [paused, setPaused] = useState(false); const [aspect, setAspect] = useState(displayAspect); const [mode, setMode] = useState<"sim" | "live">("sim"); const [liveError, setLiveError] = useState(null); - const [world, setWorld] = useState(() => { + const [backend, setBackend] = useState("webgl"); + const [searchQ, setSearchQ] = useState("drone swarm perimeter patrol"); + const [searchBusy, setSearchBusy] = useState(false); + const [searchErr, setSearchErr] = useState(null); + const [hits, setHits] = useState([]); + const [, bump] = useState(0); + + const worldRef = useRef(null); + const swarmAlgoRef = useRef(swarmAlgo); + swarmAlgoRef.current = swarmAlgo; + if (worldRef.current === null) { seed(42); - return makeWorld(8, displayAspect()); - }); + worldRef.current = { ...makeWorld(8, displayAspect(), "perimeter"), swarmAlgo }; + } + const raf = useRef(0); const last = useRef(performance.now()); const liveWorld = useRef(null); - useEffect(() => { + const resetWorld = useCallback((): void => { seed(42); - setWorld(makeWorld(droneCount, aspect)); - }, [droneCount, aspect]); + worldRef.current = { + ...makeWorld(droneCount, aspect, routeModeFor(view3d)), + swarmAlgo: swarmAlgoRef.current, + }; + bump((n) => n + 1); + }, [droneCount, aspect, view3d]); + + useEffect(() => { + if (worldRef.current) { + worldRef.current = { ...worldRef.current, swarmAlgo }; + bump((n) => n + 1); + } + }, [swarmAlgo]); + + useEffect(() => { + resetWorld(); + }, [resetWorld]); useEffect(() => { let timer = 0; @@ -61,20 +96,21 @@ export default function App(): JSX.Element { }, []); useEffect(() => { - if (mode !== "sim") return; + if (view3d || mode !== "sim") return; const loop = (now: number): void => { const dt = Math.min(0.05, (now - last.current) / 1000); last.current = now; if (!paused && dt > 0) { - setWorld((w) => tick(w, dt * speedup)); + worldRef.current = tick(worldRef.current!, dt * speedup); + bump((n) => n + 1); } raf.current = requestAnimationFrame(loop); }; + last.current = performance.now(); raf.current = requestAnimationFrame(loop); return () => cancelAnimationFrame(raf.current); - }, [paused, speedup, mode]); + }, [view3d, paused, speedup, mode]); - // Live mode: poll the explorer's read-only SQL API for real poses useEffect(() => { if (mode !== "live") return; let cancelled = false; @@ -84,7 +120,11 @@ export default function App(): JSX.Element { if (cancelled) return; setLiveError(poses.length === 0 ? "no drone data in the lake yet" : null); liveWorld.current = toLiveWorld(poses, liveWorld.current, LIVE_POLL_MS / 1000); - setWorld(liveWorld.current); + if (view3d) { + liveWorld.current = { ...liveWorld.current, routeMode: "perimeter" }; + } + worldRef.current = liveWorld.current; + bump((n) => n + 1); } catch (err) { if (!cancelled) setLiveError(err instanceof Error ? err.message : String(err)); } @@ -95,34 +135,80 @@ export default function App(): JSX.Element { cancelled = true; window.clearInterval(timer); }; - }, [mode]); + }, [mode, view3d]); useEffect(() => { if (mode === "sim") { liveWorld.current = null; setLiveError(null); - seed(42); - setWorld(makeWorld(droneCount, aspect)); + resetWorld(); } - // eslint-disable-next-line react-hooks/exhaustive-deps -- reset only on mode change - }, [mode]); + }, [mode, resetWorld]); + const simEnabled = useCallback(() => mode === "sim" && !paused, [mode, paused]); + + const onStep3d = useCallback( + (dt: number) => { + worldRef.current = tick(worldRef.current!, dt * speedup); + }, + [speedup], + ); + + const onUiPulse = useCallback(() => bump((n) => n + 1), []); + + const runSearch = async (): Promise => { + setSearchBusy(true); + setSearchErr(null); + try { + const res = await searchWeb(searchQ); + setHits(res.results); + } catch (err) { + setSearchErr(err instanceof Error ? err.message : String(err)); + setHits([]); + } finally { + setSearchBusy(false); + } + }; + + useEffect(() => { + if (!view3d) return; + void runSearch(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [view3d]); + + const world = worldRef.current!; const activeLinks = useMemo( () => [...world.links.values()].sort((a, b) => b.rate - a.rate), - [world.links], + // eslint-disable-next-line react-hooks/exhaustive-deps + [world.links, world.t], + ); + const busiest = activeLinks.slice(0, view3d ? 5 : 6); + const roads = useMemo( + () => houseRoadNetwork(world.obstacles), + // eslint-disable-next-line react-hooks/exhaustive-deps + [world.obstacles, aspect], ); - const busiest = activeLinks.slice(0, 6); return ( -
+
SWARM HOUSE
- swarm data plane — pose broadcast + opportunistic bulk sync + {view3d + ? `perimeter patrol — ${backend.toUpperCase()} renderer · shader ground grid` + : "perimeter patrol — even spread on observation loop"}
+ + + {searchErr &&
{searchErr}
} +
+ {hits.map((h) => ( + +
{h.title}
+
{h.content || h.url}
+
+ score {h.score.toFixed(1)} · {h.engines.slice(0, 2).join(", ")} +
+
+ ))} + {!searchBusy && hits.length === 0 && !searchErr && ( +
no results
+ )} +
+ + )} +
-
blue line — pose broadcast (5 Hz, ~46 B)
-
green line — bulk sync (sealed partitions)
-
flash — broadcast event delivered
-
red circle — transit object crossing the area
+ {view3d ? ( + <> +
green ring — perimeter patrol path
+
amber hex pad — base station · offload & recharge
+
house mesh — static ground obstacle
+
cyan vector — tron road between houses
+
blue line — pose broadcast mesh link
+
green line — bulk sync (hot link)
+
link flash — transit intel shared over mesh
+
cyan beam — direct detection flashlight (spotter only)
+
even spread on perimeter, keep ≥1 mesh link
+
cyan saucer — transit UFO · orange when fleet tracks
+
drag orbit · scroll zoom
+
hypha / mycelium — link-flow routing on perimeter
+
frontier — pathfinders + exploit/prune
+
wave — travelling coverage pulse
+ + ) : ( + <> +
blue line — pose broadcast (5 Hz, ~46 B)
+
green line — bulk sync (sealed partitions)
+
flash — broadcast event delivered
+
roof footprint — static house (top-down)
+
cyan vector — tron road between houses
+
cyan saucer — transit UFO crossing the area
+
amber hex — base pad · data offload & recharge
+ + )}
@@ -286,9 +533,319 @@ function sy(y: number): number { return (y / AREA) * VIEW; } +function LaunchPad({ + pad, + t, + queue, +}: { + pad: NonNullable; + t: number; + queue: number; +}): JSX.Element { + const cx = sx(pad.pos.x); + const cy = sy(pad.pos.y); + const r = (pad.radius / AREA) * VIEW; + const pulse = 0.5 + 0.5 * (0.5 + 0.5 * Math.sin(t * 3.2)); + const offloading = pad.offloadingIds.length > 0; + const hex = Array.from({ length: 6 }, (_, i) => { + const a = (i / 6) * Math.PI * 2 - Math.PI / 6; + return `${cx + Math.cos(a) * r},${cy + Math.sin(a) * r * 0.92}`; + }).join(" "); + + return ( + + + + + + BASE + + {offloading && ( + <> + + + offloading… + + + )} + {queue > 0 && !offloading && ( + + queue {queue} + + )} + + ); +} + +function TronRoads({ + segments, + t, +}: { + segments: ReturnType; + t: number; +}): JSX.Element { + return ( + + {segments.map((seg, i) => { + const pulse = 0.5 + 0.5 * (0.5 + 0.5 * Math.sin(t * 2.6 + i * 0.65)); + const x1 = sx(seg.ax); + const y1 = sy(seg.ay); + const x2 = sx(seg.bx); + const y2 = sy(seg.by); + return ( + + + + + + ); + })} + + ); +} + +function StaticHouse({ + id, + x, + y, + r, +}: { + id: number; + x: number; + y: number; + r: number; +}): JSX.Element { + const variant = id % 3; + const roofLight = ["#5a6880", "#627088", "#6a7890"][variant]; + const roofDark = ["#46566c", "#4e6078", "#566880"][variant]; + const eaves = ["#7a8898", "#8290a0", "#8a98a8"][variant]; + const rot = (id * 29) % 360; + + return ( + + + + {variant === 0 && ( + <> + + + + + + )} + + {variant === 1 && ( + <> + + + + + + + + + )} + + {variant === 2 && ( + <> + + + + + + + + )} + + ); +} + +function TransitUfo({ + x, + y, + r, + heading, +}: { + x: number; + y: number; + r: number; + heading: number; + alt?: number; +}): JSX.Element { + const deg = (heading * 180) / Math.PI; + const lights = Array.from({ length: 8 }, (_, i) => { + const a = (i / 8) * Math.PI * 2; + return { cx: Math.cos(a) * r * 0.82, cy: Math.sin(a) * r * 0.26, key: i }; + }); + + return ( + + + + + + + + {lights.map((l) => ( + + ))} + + + + ); +} + function gridLines(): JSX.Element { const width = sx(areaWidth()); - const step = VIEW / 10; // one cell per 100 m in both directions + const step = VIEW / 10; const lines = []; for (let p = step; p < width; p += step) { lines.push( @@ -330,6 +887,11 @@ const styles: Record = { display: "flex", flexDirection: "column", }, + shell3d: { + height: "100vh", + minHeight: 0, + overflow: "hidden", + }, header: { display: "flex", justifyContent: "space-between", @@ -338,11 +900,31 @@ const styles: Record = { borderBottom: "1px solid #1a2436", flexWrap: "wrap", gap: 12, + minHeight: HEADER_H, + boxSizing: "border-box", }, title: { fontSize: 18, fontWeight: 700, letterSpacing: 4, color: "#8fb4e8" }, subtitle: { fontSize: 11, color: "#5a6a85", marginTop: 2 }, controls: { display: "flex", gap: 18, alignItems: "center", flexWrap: "wrap" }, label: { display: "flex", gap: 8, alignItems: "center", fontSize: 12, color: "#8b98b0" }, + select: { + background: "#16233a", + color: "#c8d8f0", + border: "1px solid #2a3c5c", + borderRadius: 4, + padding: "4px 8px", + fontSize: 12, + maxWidth: 180, + }, + checkLabel: { + display: "flex", + gap: 6, + alignItems: "center", + fontSize: 12, + color: "#8fb4e8", + cursor: "pointer", + userSelect: "none", + }, value: { minWidth: 30, color: "#dde4f0" }, button: { background: "#16233a", @@ -357,14 +939,15 @@ const styles: Record = { buttonActive: { background: "#1a3a2a", color: "#39d98a", borderColor: "#2a5c3c" }, liveOk: { fontSize: 11, color: "#39d98a" }, liveError: { fontSize: 11, color: "#c05a6a" }, - main: { display: "flex", flex: 1, gap: 0, minHeight: 0 }, - canvas: { flex: 1, minWidth: 0, display: "block" }, + main: { display: "flex", flex: 1, gap: 0, minHeight: 0, overflow: "hidden" }, + canvas2d: { flex: 1, minWidth: 0, display: "block" }, panel: { - width: 320, + width: PANEL_W, borderLeft: "1px solid #1a2436", padding: 16, overflowY: "auto", background: "#0a0f18", + scrollbarGutter: "stable", }, section: { marginBottom: 22 }, sectionTitle: { @@ -389,6 +972,47 @@ const styles: Record = { linkStat: { color: "#5a8c6e", fontSize: 10, marginTop: 1 }, dim: { color: "#5a6a85", fontSize: 11, padding: "2px 0" }, linkLabel: { fill: "#39d98a", fontSize: 10, textAnchor: "middle", fontFamily: "inherit" }, + padLabel: { + fontSize: 11, + fill: "#1a1208", + fontWeight: 700, + textAnchor: "middle", + letterSpacing: 1.2, + }, + padOffload: { + fontSize: 10, + fill: "#39d98a", + textAnchor: "middle", + }, + padQueue: { + fontSize: 10, + fill: "#ffb347", + textAnchor: "middle", + }, droneLabel: { fill: "#6a7a95", fontSize: 9, textAnchor: "middle", fontFamily: "inherit" }, obLabel: { fill: "#c05a6a", fontSize: 9, textAnchor: "middle", fontFamily: "inherit" }, + searchForm: { display: "flex", gap: 8, marginBottom: 10 }, + searchInput: { + flex: 1, + background: "#0f1624", + border: "1px solid #1a2436", + borderRadius: 6, + color: "#dde4f0", + fontFamily: "inherit", + fontSize: 11, + padding: "7px 10px", + }, + hits: { display: "flex", flexDirection: "column", gap: 8 }, + hitLink: { + display: "block", + textDecoration: "none", + color: "inherit", + padding: "8px 10px", + borderRadius: 6, + border: "1px solid #141d2e", + background: "#0c121c", + }, + hitTitle: { fontSize: 11, color: "#8fb4e8", marginBottom: 4, lineHeight: 1.35 }, + hitSnippet: { fontSize: 10, color: "#6a7a95", lineHeight: 1.45 }, + hitMeta: { fontSize: 9, color: "#5a6a85", marginTop: 5 }, }; diff --git a/prototype/src/SwarmScene.ts b/prototype/src/SwarmScene.ts new file mode 100644 index 0000000..a76a8ac --- /dev/null +++ b/prototype/src/SwarmScene.ts @@ -0,0 +1,1434 @@ +import * as THREE from "three"; +import { WebGPURenderer } from "three/webgpu"; +import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; +import { + AREA, + BASE_PAD_RADIUS, + DETECT_RANGE, + LINK_RANGE, + areaWidth, + droneDetectsTransit, + fmtBytes, + houseRoadNetwork, + transitKnownByFleet, + type LinkStats, + type Obstacle, + type RoadSegment, + type World, +} from "./sim"; + +export type RenderBackend = "webgpu" | "webgl"; + +const LINK_BLUE = 0x3d7bd9; +const LINK_GREEN = 0x39d98a; +const TRON_CYAN = 0x00eeff; +const GLOW_SCALE = 0.5; +const TRANSIT_MARGIN = 120; + +const BEAM_VERT = /* glsl */ ` + varying vec3 vPos; + varying vec3 vWorldPos; + + void main() { + vPos = position; + vec4 world = modelMatrix * vec4(position, 1.0); + vWorldPos = world.xyz; + gl_Position = projectionMatrix * viewMatrix * world; + } +`; + +const BEAM_FRAG = /* glsl */ ` + varying vec3 vPos; + varying vec3 vWorldPos; + uniform float uTime; + uniform float uStrength; + + float hash(vec3 p) { + return fract(sin(dot(p, vec3(127.1, 311.7, 74.7))) * 43758.5453123); + } + + float noise3(vec3 p) { + vec3 i = floor(p); + vec3 f = fract(p); + vec3 u = f * f * (3.0 - 2.0 * f); + return mix( + mix(mix(hash(i), hash(i + vec3(1,0,0)), u.x), + mix(hash(i + vec3(0,1,0)), hash(i + vec3(1,1,0)), u.x), u.y), + mix(mix(hash(i + vec3(0,0,1)), hash(i + vec3(1,0,1)), u.x), + mix(hash(i + vec3(0,1,1)), hash(i + vec3(1,1,1)), u.x), u.y), + u.z + ); + } + + float fbm(vec3 p) { + float v = 0.0; + float a = 0.55; + for (int i = 0; i < 4; i++) { + v += a * noise3(p); + p *= 2.03; + a *= 0.5; + } + return v; + } + + void main() { + float axial = clamp(vPos.y + 0.5, 0.0, 1.0); + float radial = length(vPos.xz) / max(0.05, 0.22 + axial * 0.78); + radial = pow(clamp(radial, 0.0, 1.0), 1.35); + + vec3 sample = vWorldPos * 0.05 + vec3(sin(uTime * 0.8) * 0.4, uTime * 0.65, cos(uTime * 0.55) * 0.4); + float dust = fbm(sample * 3.2); + dust = pow(dust, 1.25); + float speck = smoothstep(0.48, 0.9, dust); + + float cone = pow(1.0 - radial, 2.2) * (1.0 - axial * 0.08); + float core = pow(1.0 - radial, 4.0) * (1.0 - axial * 0.05) * 0.45; + float haze = cone * 0.1; + float particles = cone * speck * 0.85; + float alpha = (haze + particles + core) * uStrength; + alpha *= 0.5 + 0.5 * sin(axial * 14.0 - uTime * 5.0 + vWorldPos.x * 0.05); + + vec3 col = mix(vec3(0.55, 0.78, 1.0), vec3(0.92, 0.97, 1.0), speck + core * 0.5); + if (alpha < 0.025) discard; + gl_FragColor = vec4(col, alpha); + } +`; + +const GROUND_VERT = /* glsl */ ` + varying vec2 vUv; + varying vec3 vWorldPos; + varying vec3 vNormal; + varying float vElevation; + + float hash(vec2 p) { + return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); + } + + float noise(vec2 p) { + vec2 i = floor(p); + vec2 f = fract(p); + vec2 u = f * f * (3.0 - 2.0 * f); + float a = hash(i); + float b = hash(i + vec2(1.0, 0.0)); + float c = hash(i + vec2(0.0, 1.0)); + float d = hash(i + vec2(1.0, 1.0)); + return mix(mix(a, b, u.x), mix(c, d, u.x), u.y); + } + + float fbm(vec2 p) { + float v = 0.0; + float a = 0.5; + for (int i = 0; i < 5; i++) { + v += a * noise(p); + p *= 2.1; + a *= 0.5; + } + return v; + } + + float terrainHeight(vec2 xz) { + float h = fbm(xz * 0.004) * 28.0; + h += fbm(xz * 0.015 + 12.0) * 8.0; + h += sin(xz.x * 0.008) * cos(xz.y * 0.007) * 3.0; + return h - 8.0; + } + + void main() { + vUv = uv; + vec3 pos = position; + float h = terrainHeight(pos.xz); + float eps = 2.5; + float hx = terrainHeight(pos.xz + vec2(eps, 0.0)) - h; + float hz = terrainHeight(pos.xz + vec2(0.0, eps)) - h; + pos.y += h; + vNormal = normalize(vec3(-hx / eps, 1.0, -hz / eps)); + vElevation = h; + vWorldPos = (modelMatrix * vec4(pos, 1.0)).xyz; + gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0); + } +`; + +const GROUND_FRAG = /* glsl */ ` + varying vec2 vUv; + varying vec3 vWorldPos; + varying vec3 vNormal; + varying float vElevation; + uniform float uTime; + uniform vec2 uFieldSize; + + float gridLine(vec2 p, float scale) { + vec2 g = abs(fract(p * scale - 0.5) - 0.5) / fwidth(p * scale); + return 1.0 - min(min(g.x, g.y), 1.0); + } + + void main() { + vec2 field = vWorldPos.xz + uFieldSize * 0.5; + vec3 n = normalize(vNormal); + vec3 sunDir = normalize(vec3(0.35, 0.88, 0.22)); + float ndl = max(dot(n, sunDir), 0.0); + + vec3 grassLow = vec3(0.10, 0.24, 0.12); + vec3 grassHi = vec3(0.16, 0.34, 0.18); + vec3 dirt = vec3(0.24, 0.20, 0.13); + vec3 rock = vec3(0.30, 0.32, 0.34); + vec3 col = mix(grassLow, grassHi, smoothstep(0.0, 14.0, vElevation)); + col = mix(col, dirt, smoothstep(10.0, 24.0, vElevation)); + col = mix(col, rock, smoothstep(0.45, 0.88, 1.0 - n.y)); + col *= 0.42 + ndl * 0.72; + + float slope = 1.0 - n.y; + col += rock * slope * 0.18; + + float grid = gridLine(field, 0.01) * 0.12 + gridLine(field, 0.05) * 0.06; + col = mix(col, vec3(0.18, 0.30, 0.48), grid); + + float edgeX = smoothstep(0.0, 18.0, field.x) * smoothstep(uFieldSize.x, uFieldSize.x - 18.0, field.x); + float edgeZ = smoothstep(0.0, 18.0, field.y) * smoothstep(uFieldSize.y, uFieldSize.y - 18.0, field.y); + float perimeter = (1.0 - edgeX * edgeZ); + float pulse = 0.5 + 0.5 * sin(uTime * 2.2 + field.x * 0.02 + field.y * 0.015); + vec3 edgeCol = vec3(0.28, 0.78, 1.0) * (0.55 + pulse * 0.35); + col = mix(col, edgeCol, perimeter * 0.55); + + float dist = length(vWorldPos.xz) / max(uFieldSize.x, uFieldSize.y); + col *= 1.0 - dist * 0.18; + + gl_FragColor = vec4(col, 1.0); + } +`; + +const SKY_VERT = /* glsl */ ` + varying vec3 vDir; + void main() { + vDir = normalize(position); + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); + } +`; + +const SKY_FRAG = /* glsl */ ` + varying vec3 vDir; + uniform float uTime; + + void main() { + float h = normalize(vDir).y * 0.5 + 0.5; + vec3 top = vec3(0.04, 0.08, 0.16); + vec3 horizon = vec3(0.12, 0.20, 0.34); + vec3 col = mix(horizon, top, pow(h, 1.4)); + float glow = exp(-abs(vDir.y) * 6.0) * (0.35 + 0.15 * sin(uTime * 0.4)); + col += vec3(0.05, 0.12, 0.22) * glow; + gl_FragColor = vec4(col, 1.0); + } +`; + +export interface SceneHandle { + dispose(): void; + backend: RenderBackend; +} + +export interface SimDriver { + step: (dt: number) => void; + enabled: () => boolean; + onUiPulse?: () => void; + uiIntervalMs?: number; +} + +function worldToScene(x: number, y: number, width: number): THREE.Vector3 { + return new THREE.Vector3(x - width / 2, 0, y - AREA / 2); +} + +function hash2(x: number, y: number): number { + const s = Math.sin(x * 127.1 + y * 311.7) * 43758.5453; + return s - Math.floor(s); +} + +function noise2(x: number, y: number): number { + const ix = Math.floor(x); + const iy = Math.floor(y); + const fx = x - ix; + const fy = y - iy; + const ux = fx * fx * (3 - 2 * fx); + const uy = fy * fy * (3 - 2 * fy); + const a = hash2(ix, iy); + const b = hash2(ix + 1, iy); + const c = hash2(ix, iy + 1); + const d = hash2(ix + 1, iy + 1); + return THREE.MathUtils.lerp( + THREE.MathUtils.lerp(a, b, ux), + THREE.MathUtils.lerp(c, d, ux), + uy, + ); +} + +function fbm2(x: number, y: number): number { + let v = 0; + let a = 0.5; + let sx = x; + let sy = y; + for (let i = 0; i < 5; i++) { + v += a * noise2(sx, sy); + sx *= 2.1; + sy *= 2.1; + a *= 0.5; + } + return v; +} + +function terrainHeight(x: number, z: number): number { + const h = + fbm2(x * 0.004, z * 0.004) * 28 + + fbm2(x * 0.015 + 12, z * 0.015 + 12) * 8 + + Math.sin(x * 0.008) * Math.cos(z * 0.007) * 3; + return h - 8; +} + +function terrainSegments(): number { + return 256; +} + +function createGroundMaterial(fieldW: number): THREE.ShaderMaterial { + return new THREE.ShaderMaterial({ + uniforms: { + uTime: { value: 0 }, + uFieldSize: { value: new THREE.Vector2(fieldW, AREA) }, + }, + vertexShader: GROUND_VERT, + fragmentShader: GROUND_FRAG, + }); +} + +function createSky(): THREE.Mesh { + const geo = new THREE.SphereGeometry(2400, 32, 16); + const mat = new THREE.ShaderMaterial({ + uniforms: { uTime: { value: 0 } }, + vertexShader: SKY_VERT, + fragmentShader: SKY_FRAG, + side: THREE.BackSide, + depthWrite: false, + }); + return new THREE.Mesh(geo, mat); +} + +function transitOnField(ob: Obstacle, fieldW: number): boolean { + if (!ob.moving) return false; + return ( + ob.pos.x > -TRANSIT_MARGIN && + ob.pos.x < fieldW + TRANSIT_MARGIN && + ob.pos.y > -TRANSIT_MARGIN && + ob.pos.y < AREA + TRANSIT_MARGIN + ); +} + +interface DetectionBeam { + mesh: THREE.Mesh; + mat: THREE.ShaderMaterial; +} + +function createDetectionBeam(geo: THREE.BufferGeometry): DetectionBeam { + const mat = new THREE.ShaderMaterial({ + uniforms: { + uTime: { value: 0 }, + uStrength: { value: 1 }, + }, + vertexShader: BEAM_VERT, + fragmentShader: BEAM_FRAG, + transparent: true, + depthWrite: false, + blending: THREE.AdditiveBlending, + side: THREE.DoubleSide, + }); + const mesh = new THREE.Mesh(geo, mat); + mesh.renderOrder = 3; + mesh.frustumCulled = false; + return { mesh, mat }; +} + +function orientDetectionBeam( + mesh: THREE.Mesh, + from: THREE.Vector3, + to: THREE.Vector3, +): number { + const dir = new THREE.Vector3().subVectors(to, from); + const len = dir.length(); + if (len < 6) { + mesh.visible = false; + return 0; + } + mesh.visible = true; + const spread = Math.max(6, len * Math.tan(0.11)); + const dirN = dir.normalize(); + mesh.scale.set(spread, len, spread); + mesh.position.copy(from).add(dirN.clone().multiplyScalar(len * 0.5)); + mesh.quaternion.setFromUnitVectors(new THREE.Vector3(0, -1, 0), dirN); + return len; +} + +function scenePoint( + simX: number, + simY: number, + agl: number, + fieldW: number, +): THREE.Vector3 { + const p = worldToScene(simX, simY, fieldW); + p.y = terrainHeight(p.x, p.z) + agl; + return p; +} + +function createGlowTexture(color: number): THREE.CanvasTexture { + const canvas = document.createElement("canvas"); + canvas.width = 128; + canvas.height = 128; + const ctx = canvas.getContext("2d")!; + const r = (color >> 16) & 255; + const g = (color >> 8) & 255; + const b = color & 255; + const grad = ctx.createRadialGradient(64, 64, 0, 64, 64, 64); + grad.addColorStop(0, `rgba(${r},${g},${b},1)`); + grad.addColorStop(0.35, `rgba(${r},${g},${b},0.55)`); + grad.addColorStop(0.75, `rgba(${r},${g},${b},0.12)`); + grad.addColorStop(1, "rgba(0,0,0,0)"); + ctx.fillStyle = grad; + ctx.fillRect(0, 0, 128, 128); + const tex = new THREE.CanvasTexture(canvas); + tex.needsUpdate = true; + return tex; +} + +function createGlowSprite(color: number, size: number): THREE.Sprite { + const mat = new THREE.SpriteMaterial({ + map: createGlowTexture(color), + transparent: true, + blending: THREE.AdditiveBlending, + depthWrite: false, + opacity: 0.45, + }); + const sprite = new THREE.Sprite(mat); + sprite.scale.set(size, size, 1); + sprite.renderOrder = 10; + return sprite; +} + +const HOUSE_WALLS = [0x3a4558, 0x424e62, 0x4a5668]; +const HOUSE_ROOFS = [0x526078, 0x5a6880, 0x627088]; +const HOUSE_TRIM = [0x6a7688, 0x728090, 0x7a8898]; + +function createHouseMesh(): THREE.Group { + const g = new THREE.Group(); + g.userData.kind = "static"; + + const wallMat = new THREE.MeshStandardMaterial({ + color: 0x3a4558, + roughness: 0.82, + metalness: 0.08, + }); + const roofMat = new THREE.MeshStandardMaterial({ + color: 0x526078, + roughness: 0.72, + metalness: 0.14, + }); + const trimMat = new THREE.MeshStandardMaterial({ + color: 0x6a7688, + roughness: 0.68, + metalness: 0.1, + }); + const windowMat = new THREE.MeshStandardMaterial({ + color: 0xffe6b0, + emissive: 0xffaa44, + emissiveIntensity: 0.65, + roughness: 0.35, + }); + const doorMat = new THREE.MeshStandardMaterial({ + color: 0x2a3240, + roughness: 0.9, + metalness: 0.05, + }); + + const foundation = new THREE.Mesh(new THREE.BoxGeometry(2.05, 0.12, 1.55), trimMat); + foundation.position.y = 0.06; + foundation.receiveShadow = true; + g.add(foundation); + + const body = new THREE.Mesh(new THREE.BoxGeometry(1.75, 0.78, 1.25), wallMat); + body.position.y = 0.5; + body.castShadow = true; + body.receiveShadow = true; + g.add(body); + + const wing = new THREE.Mesh(new THREE.BoxGeometry(0.85, 0.62, 0.72), wallMat); + wing.position.set(1.05, 0.42, 0.22); + wing.castShadow = true; + g.add(wing); + + const roofLeft = new THREE.Mesh(new THREE.BoxGeometry(1.05, 0.1, 1.35), roofMat); + roofLeft.position.set(-0.28, 0.98, 0); + roofLeft.rotation.z = Math.PI / 4.8; + roofLeft.castShadow = true; + g.add(roofLeft); + + const roofRight = new THREE.Mesh(new THREE.BoxGeometry(1.05, 0.1, 1.35), roofMat); + roofRight.position.set(0.28, 0.98, 0); + roofRight.rotation.z = -Math.PI / 4.8; + roofRight.castShadow = true; + g.add(roofRight); + + const roofWing = new THREE.Mesh(new THREE.BoxGeometry(0.62, 0.08, 0.78), roofMat); + roofWing.position.set(1.05, 0.78, 0.22); + roofWing.rotation.z = -Math.PI / 5.5; + g.add(roofWing); + + const chimney = new THREE.Mesh(new THREE.BoxGeometry(0.2, 0.48, 0.2), trimMat); + chimney.position.set(-0.52, 1.18, -0.28); + chimney.castShadow = true; + g.add(chimney); + + const chimneyCap = new THREE.Mesh( + new THREE.BoxGeometry(0.28, 0.06, 0.28), + new THREE.MeshStandardMaterial({ color: 0x8898a8, roughness: 0.6, metalness: 0.2 }), + ); + chimneyCap.position.set(-0.52, 1.44, -0.28); + g.add(chimneyCap); + + const porch = new THREE.Mesh(new THREE.BoxGeometry(0.52, 0.1, 0.38), trimMat); + porch.position.set(0, 0.14, 0.78); + g.add(porch); + + const door = new THREE.Mesh(new THREE.BoxGeometry(0.26, 0.42, 0.06), doorMat); + door.position.set(0, 0.36, 0.68); + g.add(door); + + const windowDefs = [ + [-0.48, 0.52, 0.66], + [0.42, 0.52, 0.66], + [1.12, 0.46, 0.38], + [-0.48, 0.52, -0.58], + [0.42, 0.52, -0.58], + ] as const; + const windows: THREE.Mesh[] = []; + for (const [wx, wy, wz] of windowDefs) { + const win = new THREE.Mesh(new THREE.BoxGeometry(0.22, 0.2, 0.05), windowMat); + win.position.set(wx, wy, wz); + g.add(win); + windows.push(win); + } + + g.userData.wallMat = wallMat; + g.userData.roofMat = roofMat; + g.userData.trimMat = trimMat; + g.userData.windowMats = windows.map((w) => w.material as THREE.MeshStandardMaterial); + + return g; +} + +function createUfoMesh(): THREE.Group { + const g = new THREE.Group(); + g.userData.kind = "ufo"; + + const underGlow = createGlowSprite(0x44ddff, 42); + underGlow.position.y = -0.35; + g.add(underGlow); + g.userData.underGlow = underGlow; + + const hullMat = new THREE.MeshStandardMaterial({ + color: 0xa8bdd8, + metalness: 0.92, + roughness: 0.12, + emissive: 0x1a3050, + emissiveIntensity: 0.45, + }); + const hull = new THREE.Mesh(new THREE.SphereGeometry(1, 20, 12, 0, Math.PI * 2, Math.PI / 2, Math.PI / 3.2), hullMat); + hull.scale.set(1, 0.35, 1); + hull.castShadow = true; + g.add(hull); + g.userData.hullMat = hullMat; + + const rimMat = new THREE.MeshStandardMaterial({ + color: 0x99ffff, + emissive: 0x22ddff, + emissiveIntensity: 1.6, + metalness: 0.7, + roughness: 0.12, + toneMapped: false, + }); + const rim = new THREE.Mesh(new THREE.TorusGeometry(1.02, 0.07, 16, 48), rimMat); + rim.rotation.x = Math.PI / 2; + rim.position.y = 0.05; + g.add(rim); + g.userData.rimMat = rimMat; + + const spinRing = new THREE.Group(); + spinRing.position.y = 0.08; + g.add(spinRing); + g.userData.spinRing = spinRing; + + const ringBand = new THREE.Mesh(new THREE.TorusGeometry(0.82, 0.035, 10, 36), rimMat); + ringBand.rotation.x = Math.PI / 2; + spinRing.add(ringBand); + + const domeMat = new THREE.MeshStandardMaterial({ + color: 0xc8e8ff, + metalness: 0.4, + roughness: 0.06, + transparent: true, + opacity: 0.82, + emissive: 0x55aaff, + emissiveIntensity: 0.9, + }); + const dome = new THREE.Mesh( + new THREE.SphereGeometry(0.48, 24, 16, 0, Math.PI * 2, 0, Math.PI / 2), + domeMat, + ); + dome.position.y = 0.22; + g.add(dome); + g.userData.domeMat = domeMat; + + const coreGlow = new THREE.Mesh( + new THREE.SphereGeometry(0.22, 14, 14), + new THREE.MeshBasicMaterial({ + color: 0xbbffff, + transparent: true, + opacity: 0.95, + blending: THREE.AdditiveBlending, + depthWrite: false, + }), + ); + coreGlow.position.y = 0.34; + g.add(coreGlow); + g.userData.coreGlow = coreGlow; + + const rimLights: THREE.MeshStandardMaterial[] = []; + for (let i = 0; i < 8; i++) { + const a = (i / 8) * Math.PI * 2; + const lightMat = new THREE.MeshStandardMaterial({ + color: 0xffffff, + emissive: 0x00ffdd, + emissiveIntensity: 2.6, + toneMapped: false, + }); + const light = new THREE.Mesh(new THREE.SphereGeometry(0.065, 8, 8), lightMat); + light.position.set(Math.cos(a) * 1.02, 0.04, Math.sin(a) * 1.02); + spinRing.add(light); + rimLights.push(lightMat); + } + g.userData.rimLights = rimLights; + + const halo = createGlowSprite(0x66eeff, 22); + halo.position.y = 0.25; + g.add(halo); + g.userData.halo = halo; + + return g; +} + +function pushObstacleToPool( + visual: THREE.Object3D, + staticPool: THREE.Group[], + ufoPool: THREE.Group[], +): void { + visual.visible = false; + if (visual.userData.kind === "ufo") { + ufoPool.push(visual as THREE.Group); + } else { + staticPool.push(visual as THREE.Group); + } +} + +function orientCylinder( + mesh: THREE.Mesh, + a: THREE.Vector3, + b: THREE.Vector3, + radius = 1, +): void { + const dir = new THREE.Vector3().subVectors(b, a); + const len = dir.length(); + if (len < 0.01) { + mesh.visible = false; + return; + } + mesh.visible = true; + mesh.scale.set(radius, len, radius); + mesh.position.copy(a).add(b).multiplyScalar(0.5); + mesh.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), dir.normalize()); +} + +interface LinkVisual { + line: THREE.Line; + mat: THREE.LineBasicMaterial; +} + +function createLinkVisual(): LinkVisual { + const mat = new THREE.LineBasicMaterial({ + color: LINK_BLUE, + transparent: true, + opacity: 0.25, + }); + const line = new THREE.Line(new THREE.BufferGeometry(), mat); + return { line, mat }; +} + +function disposeRoadGroup(group: THREE.Group): void { + const glowMat = group.userData.glowMat as THREE.Material | undefined; + const coreMat = group.userData.coreMat as THREE.Material | undefined; + group.traverse((o) => { + if (o instanceof THREE.Mesh || o instanceof THREE.Line) o.geometry.dispose(); + }); + glowMat?.dispose(); + coreMat?.dispose(); +} + +function buildTronRoadGroup(segments: RoadSegment[], fieldW: number): THREE.Group { + const group = new THREE.Group(); + group.userData.glowMat = new THREE.MeshBasicMaterial({ + color: TRON_CYAN, + transparent: true, + blending: THREE.AdditiveBlending, + depthWrite: false, + opacity: 0.1, + }); + group.userData.coreMat = new THREE.LineBasicMaterial({ + color: TRON_CYAN, + transparent: true, + opacity: 0.62, + }); + const glowMat = group.userData.glowMat as THREE.MeshBasicMaterial; + const coreMat = group.userData.coreMat as THREE.LineBasicMaterial; + + for (const seg of segments) { + const a = worldToScene(seg.ax, seg.ay, fieldW); + const b = worldToScene(seg.bx, seg.by, fieldW); + a.y = terrainHeight(a.x, a.z) + 1.2; + b.y = terrainHeight(b.x, b.z) + 1.2; + const glow = new THREE.Mesh(new THREE.CylinderGeometry(0.75, 0.75, 1, 6, 1, true), glowMat); + orientCylinder(glow, a, b, 0.75); + group.add(glow); + group.add(new THREE.Line(new THREE.BufferGeometry().setFromPoints([a, b]), coreMat)); + } + return group; +} + +function createPerimeterLoop(width: number): THREE.Group { + const margin = 120; + const raw = [ + new THREE.Vector3(margin - width / 2, 0, margin - AREA / 2), + new THREE.Vector3(width - margin - width / 2, 0, 150 - AREA / 2), + new THREE.Vector3(width - margin - width / 2, 0, AREA - margin - AREA / 2), + new THREE.Vector3(150 - width / 2, 0, AREA - margin - AREA / 2), + ]; + const pts = raw.map((p) => { + const y = terrainHeight(p.x, p.z) + 0.8; + return new THREE.Vector3(p.x, y, p.z); + }); + const group = new THREE.Group(); + group.userData.glowMat = new THREE.MeshBasicMaterial({ + color: 0x39d98a, + transparent: true, + blending: THREE.AdditiveBlending, + depthWrite: false, + opacity: 0.11, + }); + group.userData.coreMat = new THREE.LineBasicMaterial({ + color: 0x39d98a, + transparent: true, + opacity: 0.55, + }); + const glowMat = group.userData.glowMat as THREE.MeshBasicMaterial; + const coreMat = group.userData.coreMat as THREE.LineBasicMaterial; + for (let i = 0; i < pts.length; i++) { + const a = pts[i]; + const b = pts[(i + 1) % pts.length]; + const glow = new THREE.Mesh(new THREE.CylinderGeometry(1.4, 1.4, 1, 8, 1, true), glowMat); + orientCylinder(glow, a, b, 1.4); + group.add(glow); + const coreGeo = new THREE.BufferGeometry().setFromPoints([a, b]); + group.add(new THREE.Line(coreGeo, coreMat)); + } + return group; +} + +function disposePerimeter(group: THREE.Group): void { + const glowMat = group.userData.glowMat as THREE.Material | undefined; + const coreMat = group.userData.coreMat as THREE.Material | undefined; + group.traverse((o) => { + if (o instanceof THREE.Mesh || o instanceof THREE.Line) o.geometry.dispose(); + }); + glowMat?.dispose(); + coreMat?.dispose(); +} + +function createBasePadMesh(): THREE.Group { + const g = new THREE.Group(); + g.userData.kind = "basePad"; + const deckMat = new THREE.MeshStandardMaterial({ + color: 0x1a2438, + metalness: 0.65, + roughness: 0.35, + emissive: 0x332208, + emissiveIntensity: 0.35, + }); + const ringMat = new THREE.MeshStandardMaterial({ + color: 0xffb347, + emissive: 0xff8822, + emissiveIntensity: 1.1, + metalness: 0.5, + roughness: 0.25, + toneMapped: false, + }); + const deck = new THREE.Mesh(new THREE.CylinderGeometry(BASE_PAD_RADIUS * 0.92, BASE_PAD_RADIUS, 1.2, 6), deckMat); + deck.castShadow = true; + deck.receiveShadow = true; + g.add(deck); + g.userData.deckMat = deckMat; + + const ring = new THREE.Mesh(new THREE.TorusGeometry(BASE_PAD_RADIUS * 0.88, 1.8, 8, 6), ringMat); + ring.rotation.x = Math.PI / 2; + ring.position.y = 0.65; + g.add(ring); + g.userData.ringMat = ringMat; + + const beacon = createGlowSprite(0xffaa44, 28); + beacon.position.y = 4.5; + g.add(beacon); + g.userData.beacon = beacon; + + const uplink = new THREE.Mesh( + new THREE.CylinderGeometry(2.5, 3.5, 6, 8), + new THREE.MeshStandardMaterial({ + color: 0x8898b0, + metalness: 0.8, + roughness: 0.2, + emissive: 0x223344, + emissiveIntensity: 0.4, + }), + ); + uplink.position.y = 4; + g.add(uplink); + g.userData.uplink = uplink; + + return g; +} + +function createDroneMesh(): THREE.Group { + const g = new THREE.Group(); + const S = 1.4; + + const beacon = createGlowSprite(0x8fb4e8, 18); + beacon.position.y = -1.5; + g.add(beacon); + + const halo = createGlowSprite(0x5eb0ff, 14); + halo.position.y = 0.3; + g.add(halo); + + const body = new THREE.Mesh( + new THREE.ConeGeometry(2.2, 5.5, 4), + new THREE.MeshStandardMaterial({ + color: 0xf4f7ff, + metalness: 0.2, + roughness: 0.35, + emissive: 0x3d7bd9, + emissiveIntensity: 0.28, + }), + ); + body.rotation.x = Math.PI / 2; + g.add(body); + + const core = new THREE.Mesh( + new THREE.SphereGeometry(0.9, 10, 10), + new THREE.MeshStandardMaterial({ + color: 0xd0e8ff, + emissive: 0x66aaff, + emissiveIntensity: 1.1, + toneMapped: false, + }), + ); + g.add(core); + + const coreGlow = new THREE.Mesh( + new THREE.SphereGeometry(1.8, 10, 10), + new THREE.MeshBasicMaterial({ + color: 0x4488ff, + transparent: true, + opacity: 0.14, + blending: THREE.AdditiveBlending, + depthWrite: false, + }), + ); + coreGlow.renderOrder = 8; + g.add(coreGlow); + + const armMat = new THREE.MeshStandardMaterial({ + color: 0x9aaccc, + metalness: 0.45, + roughness: 0.45, + emissive: 0x1a3050, + emissiveIntensity: 0.35, + }); + for (let i = 0; i < 4; i++) { + const arm = new THREE.Mesh(new THREE.BoxGeometry(5.5, 0.25, 0.25), armMat); + arm.rotation.y = (i * Math.PI) / 2; + g.add(arm); + const rotor = new THREE.Mesh( + new THREE.TorusGeometry(1.1, 0.08, 6, 16), + new THREE.MeshStandardMaterial({ + color: 0xe8f0ff, + emissive: 0x39d98a, + emissiveIntensity: 0.7, + toneMapped: false, + }), + ); + rotor.rotation.x = Math.PI / 2; + rotor.position.set(Math.cos(arm.rotation.y) * 2.6, 0.2, Math.sin(arm.rotation.y) * 2.6); + g.add(rotor); + const rotorGlow = createGlowSprite(0x39d98a, 5); + rotorGlow.position.copy(rotor.position); + rotorGlow.position.y = 0.15; + g.add(rotorGlow); + } + + g.scale.setScalar(S); + g.userData.beacon = beacon; + g.userData.halo = halo; + g.userData.core = core; + g.userData.coreGlow = coreGlow; + return g; +} + +async function createRenderer(canvas: HTMLCanvasElement): Promise<{ + renderer: THREE.WebGLRenderer | WebGPURenderer; + backend: RenderBackend; +}> { + if ("gpu" in navigator) { + try { + const webgpu = new WebGPURenderer({ canvas, antialias: true }); + webgpu.setPixelRatio(Math.min(window.devicePixelRatio, 2)); + await webgpu.init(); + webgpu.toneMapping = THREE.ACESFilmicToneMapping; + webgpu.toneMappingExposure = 1.25; + return { renderer: webgpu, backend: "webgpu" }; + } catch { + // fall through to WebGL + } + } + const webgl = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: false }); + webgl.setPixelRatio(Math.min(window.devicePixelRatio, 2)); + webgl.shadowMap.enabled = true; + webgl.shadowMap.type = THREE.PCFSoftShadowMap; + webgl.toneMapping = THREE.ACESFilmicToneMapping; + webgl.toneMappingExposure = 1.25; + return { renderer: webgl, backend: "webgl" }; +} + +function frameField( + camera: THREE.PerspectiveCamera, + controls: OrbitControls, + fieldW: number, + viewAspect: number, +): void { + const margin = 1.25; + const halfW = fieldW / 2; + const halfD = AREA / 2; + const vFov = (camera.fov * Math.PI) / 180; + const hFov = 2 * Math.atan(Math.tan(vFov / 2) * viewAspect); + + const polar = Math.PI * 0.36; + const elevation = Math.PI / 2 - polar; + const distForDepth = halfD / (Math.tan(vFov / 2) * Math.max(0.35, Math.cos(elevation))); + const distForWidth = halfW / Math.tan(hFov / 2); + const distance = Math.max(distForDepth, distForWidth) * margin; + + const azimuth = Math.PI * 0.22; + const offset = new THREE.Vector3().setFromSphericalCoords(distance, polar, azimuth); + + camera.position.copy(offset); + controls.target.set(0, 0, 0); + controls.minDistance = distance * 0.25; + controls.maxDistance = distance * 2.8; + controls.update(); +} + +export async function mountSwarmScene( + container: HTMLElement, + canvas: HTMLCanvasElement, + getWorld: () => World, + sim?: SimDriver, +): Promise { + const scene = new THREE.Scene(); + scene.fog = new THREE.FogExp2(0x0a1220, 0.00028); + + const camera = new THREE.PerspectiveCamera(52, 1, 0.5, 6000); + + const { renderer, backend } = await createRenderer(canvas); + const controls = new OrbitControls(camera, container); + controls.enableDamping = true; + controls.dampingFactor = 0.08; + controls.rotateSpeed = 0.65; + controls.maxPolarAngle = Math.PI / 2.05; + controls.minDistance = 120; + controls.maxDistance = 2400; + controls.target.set(0, 0, 0); + controls.mouseButtons = { + LEFT: THREE.MOUSE.ROTATE, + MIDDLE: THREE.MOUSE.DOLLY, + RIGHT: THREE.MOUSE.PAN, + }; + + let dragging = false; + controls.addEventListener("start", () => { + dragging = true; + userFramed = true; + }); + controls.addEventListener("end", () => { + dragging = false; + }); + + const skyMesh = createSky(); + scene.add(skyMesh); + + const hemi = new THREE.HemisphereLight(0xa8c8f0, 0x1a2436, 0.85); + scene.add(hemi); + const sun = new THREE.DirectionalLight(0xf0f4ff, 1.35); + sun.position.set(280, 520, 180); + sun.castShadow = true; + sun.shadow.mapSize.set(2048, 2048); + sun.shadow.camera.near = 10; + sun.shadow.camera.far = 1600; + const sh = 520; + sun.shadow.camera.left = -sh; + sun.shadow.camera.right = sh; + sun.shadow.camera.top = sh; + sun.shadow.camera.bottom = -sh; + scene.add(sun); + const fill = new THREE.DirectionalLight(0x6699cc, 0.45); + fill.position.set(-320, 260, -240); + scene.add(fill); + const ambient = new THREE.AmbientLight(0x334466, 0.35); + scene.add(ambient); + + let fieldW = areaWidth(); + const groundMat = createGroundMaterial(fieldW); + const ground = new THREE.Mesh( + new THREE.PlaneGeometry(fieldW, AREA, terrainSegments(), terrainSegments()), + groundMat, + ); + ground.rotation.x = -Math.PI / 2; + ground.receiveShadow = true; + scene.add(ground); + + let perimeter = createPerimeterLoop(fieldW); + scene.add(perimeter); + + const basePadMesh = createBasePadMesh(); + scene.add(basePadMesh); + + let roadGroup = buildTronRoadGroup(houseRoadNetwork(getWorld().obstacles), fieldW); + scene.add(roadGroup); + let roadSig = ""; + + const beamGeo = new THREE.CylinderGeometry(1, 0.003, 1, 8, 1, true); + + const linkLines = new Map(); + const detectBeams = new Map(); + const beamPool: DetectionBeam[] = []; + + const droneMeshes: THREE.Group[] = []; + const dronePool: THREE.Group[] = []; + + const obstacleMeshes: THREE.Object3D[] = []; + const staticObstaclePool: THREE.Group[] = []; + const ufoObstaclePool: THREE.Group[] = []; + + let raf = 0; + let disposed = false; + const clock = new THREE.Clock(); + let lastUiPulse = 0; + let lastW = 0; + let lastH = 0; + let userFramed = false; + + const resize = (reframe = false): void => { + if (dragging) return; + const w = container.clientWidth; + const h = container.clientHeight; + if (w === 0 || h === 0) return; + const sizeChanged = Math.abs(w - lastW) >= 2 || Math.abs(h - lastH) >= 2; + if (!sizeChanged && !reframe) return; + lastW = w; + lastH = h; + camera.aspect = w / h; + camera.updateProjectionMatrix(); + renderer.setSize(w, h, false); + if (!userFramed || reframe) { + frameField(camera, controls, fieldW, w / h); + } + }; + + const syncField = (width: number): void => { + if (width === fieldW) return; + fieldW = width; + ground.geometry.dispose(); + ground.geometry = new THREE.PlaneGeometry(fieldW, AREA, terrainSegments(), terrainSegments()); + (groundMat.uniforms.uFieldSize.value as THREE.Vector2).set(fieldW, AREA); + scene.remove(perimeter); + disposePerimeter(perimeter); + perimeter = createPerimeterLoop(fieldW); + scene.add(perimeter); + if (!dragging) resize(true); + }; + + const syncDrones = (world: World): void => { + while (droneMeshes.length < world.drones.length) { + const mesh = dronePool.pop() ?? createDroneMesh(); + mesh.visible = true; + scene.add(mesh); + droneMeshes.push(mesh); + } + while (droneMeshes.length > world.drones.length) { + const mesh = droneMeshes.pop(); + if (!mesh) break; + mesh.visible = false; + scene.remove(mesh); + dronePool.push(mesh); + } + + world.drones.forEach((d, i) => { + const mesh = droneMeshes[i]; + const p = worldToScene(d.pos.x, d.pos.y, fieldW); + const alt = d.alt ?? 22; + const groundY = terrainHeight(p.x, p.z); + mesh.position.set(p.x, groundY + alt, p.z); + mesh.rotation.y = -d.heading + Math.PI / 2; + const climb = THREE.MathUtils.clamp(d.altVel ?? 0, -8, 8) * 0.035; + mesh.rotation.x = climb; + const landed = d.phase === "landed"; + const pulse = landed ? 0.25 : 0.45 + 0.55 * (0.5 + 0.5 * Math.sin(world.t * 8 + d.id)); + const core = mesh.userData.core as THREE.Mesh; + const coreMat = core.material as THREE.MeshStandardMaterial; + coreMat.emissiveIntensity = landed ? 0.35 : 0.9 + pulse * 0.7; + const coreGlow = mesh.userData.coreGlow as THREE.Mesh; + (coreGlow.material as THREE.MeshBasicMaterial).opacity = landed + ? 0.04 + : 0.09 + pulse * 0.11; + const halo = mesh.userData.halo as THREE.Sprite; + (halo.material as THREE.SpriteMaterial).opacity = landed + ? 0.08 + : (0.28 + pulse * 0.18) * GLOW_SCALE; + const beacon = mesh.userData.beacon as THREE.Sprite; + (beacon.material as THREE.SpriteMaterial).opacity = landed + ? 0.06 + : (0.18 + pulse * 0.12) * GLOW_SCALE; + }); + }; + + const syncBasePad = (world: World, elapsed: number): void => { + const pad = world.basePad; + if (!pad) { + basePadMesh.visible = false; + return; + } + basePadMesh.visible = true; + const p = worldToScene(pad.pos.x, pad.pos.y, fieldW); + const groundY = terrainHeight(p.x, p.z); + basePadMesh.position.set(p.x, groundY, p.z); + const offloading = pad.offloadingIds.length > 0; + const pulse = 0.5 + 0.5 * (0.5 + 0.5 * Math.sin(elapsed * 4.2)); + const ringMat = basePadMesh.userData.ringMat as THREE.MeshStandardMaterial; + const deckMat = basePadMesh.userData.deckMat as THREE.MeshStandardMaterial; + ringMat.emissiveIntensity = offloading ? 1.8 + pulse * 1.2 : 0.9 + pulse * 0.4; + ringMat.color.set(offloading ? 0x66ff99 : 0xffb347); + ringMat.emissive.set(offloading ? 0x22cc66 : 0xff8822); + deckMat.emissiveIntensity = offloading ? 0.55 + pulse * 0.35 : 0.35; + const beacon = basePadMesh.userData.beacon as THREE.Sprite; + (beacon.material as THREE.SpriteMaterial).opacity = (offloading ? 0.55 : 0.35) * GLOW_SCALE * (0.7 + pulse * 0.3); + const uplink = basePadMesh.userData.uplink as THREE.Mesh; + (uplink.material as THREE.MeshStandardMaterial).emissiveIntensity = offloading + ? 1.2 + pulse * 0.8 + : 0.4; + }; + + const syncDetectionBeams = (world: World, elapsed: number): void => { + const active = new Set(); + + for (const d of world.drones) { + const from = scenePoint(d.pos.x, d.pos.y, d.alt ?? 22, fieldW); + + for (const ob of world.obstacles) { + if (!transitOnField(ob, fieldW)) continue; + if (!droneDetectsTransit(d, ob)) continue; + + const dist = Math.hypot(d.pos.x - ob.pos.x, d.pos.y - ob.pos.y); + + const key = `${d.id}-${ob.id}`; + active.add(key); + + let beam = detectBeams.get(key); + if (!beam) { + beam = beamPool.pop() ?? createDetectionBeam(beamGeo); + detectBeams.set(key, beam); + scene.add(beam.mesh); + } + + const targetAgl = ob.radius * 0.75 + 10; + const to = scenePoint(ob.pos.x, ob.pos.y, targetAgl, fieldW); + const len = orientDetectionBeam(beam.mesh, from, to); + const strength = (1 - dist / DETECT_RANGE) * (0.55 + 0.45 * (1 - len / DETECT_RANGE)); + beam.mat.uniforms.uTime.value = elapsed; + beam.mat.uniforms.uStrength.value = Math.max(0.15, strength); + } + } + + for (const [key, beam] of detectBeams) { + if (active.has(key)) continue; + scene.remove(beam.mesh); + beam.mesh.visible = false; + beamPool.push(beam); + detectBeams.delete(key); + } + }; + + const syncRoads = (world: World, width: number, elapsed: number): void => { + const segments = houseRoadNetwork(world.obstacles); + const sig = `${width}|${segments.map((s) => `${s.ax},${s.ay},${s.bx},${s.by}`).join(";")}`; + if (sig !== roadSig) { + scene.remove(roadGroup); + disposeRoadGroup(roadGroup); + roadGroup = buildTronRoadGroup(segments, width); + scene.add(roadGroup); + roadSig = sig; + } + const pulse = 0.55 + 0.45 * (0.5 + 0.5 * Math.sin(elapsed * 2.4)); + (roadGroup.userData.coreMat as THREE.LineBasicMaterial).opacity = 0.45 + 0.35 * pulse; + (roadGroup.userData.glowMat as THREE.MeshBasicMaterial).opacity = 0.07 + 0.07 * pulse; + }; + + const syncObstacles = (world: World, elapsed: number): void => { + while (obstacleMeshes.length > world.obstacles.length) { + const mesh = obstacleMeshes.pop(); + if (!mesh) break; + scene.remove(mesh); + pushObstacleToPool(mesh, staticObstaclePool, ufoObstaclePool); + } + + world.obstacles.forEach((ob, i) => { + const wantKind = ob.moving ? "ufo" : "static"; + let visual = obstacleMeshes[i]; + if (!visual || visual.userData.kind !== wantKind) { + if (visual) { + scene.remove(visual); + pushObstacleToPool(visual, staticObstaclePool, ufoObstaclePool); + } + visual = + wantKind === "ufo" + ? (ufoObstaclePool.pop() ?? createUfoMesh()) + : (staticObstaclePool.pop() ?? createHouseMesh()); + scene.add(visual); + obstacleMeshes[i] = visual; + } + + visual.visible = true; + const p = worldToScene(ob.pos.x, ob.pos.y, fieldW); + const groundY = terrainHeight(p.x, p.z); + const tracked = ob.moving && transitKnownByFleet(world, ob.id); + + if (ob.moving) { + const ufo = visual as THREE.Group; + const flyAlt = ob.alt ?? 28; + ufo.position.set(p.x, groundY + flyAlt, p.z); + const speed = Math.hypot(ob.vel.x, ob.vel.y); + ufo.rotation.y = speed > 0.5 ? Math.atan2(ob.vel.x, ob.vel.y) : ufo.rotation.y; + ufo.rotation.z = 0; + ufo.rotation.x = 0; + const spinRing = ufo.userData.spinRing as THREE.Group | undefined; + if (spinRing) { + spinRing.rotation.y = elapsed * 2.2 + ob.id; + spinRing.position.y = 0.08 + Math.sin(elapsed * 2.4 + ob.id * 1.7) * 0.06; + } + const s = ob.radius * 0.18; + ufo.scale.set(s, s, s); + visual.userData.baseY = groundY + flyAlt; + + const pulse = 0.55 + 0.45 * (0.5 + 0.5 * Math.sin(elapsed * 5 + ob.id * 2.1)); + const rimMat = ufo.userData.rimMat as THREE.MeshStandardMaterial; + const domeMat = ufo.userData.domeMat as THREE.MeshStandardMaterial; + const hullMat = ufo.userData.hullMat as THREE.MeshStandardMaterial; + const coreGlow = ufo.userData.coreGlow as THREE.Mesh; + const underGlow = ufo.userData.underGlow as THREE.Sprite; + const rimLights = ufo.userData.rimLights as THREE.MeshStandardMaterial[]; + + if (tracked) { + rimMat.color.set(0xffaa66); + rimMat.emissive.set(0xff6622); + rimMat.emissiveIntensity = 1.6 + pulse * 0.5; + domeMat.emissive.set(0xff8844); + domeMat.emissiveIntensity = 0.95 + pulse * 0.35; + hullMat.emissive.set(0x442211); + hullMat.emissiveIntensity = 0.55; + (coreGlow.material as THREE.MeshBasicMaterial).color.set(0xffcc88); + (underGlow.material as THREE.SpriteMaterial).color.set(0xff8844); + for (const mat of rimLights) { + mat.emissive.set(0xffaa44); + mat.emissiveIntensity = 2.4 + pulse; + } + } else { + rimMat.color.set(0x88eeff); + rimMat.emissive.set(0x22ccff); + rimMat.emissiveIntensity = 1.2 + pulse * 0.45; + domeMat.emissive.set(0x4488ff); + domeMat.emissiveIntensity = 0.65 + pulse * 0.25; + hullMat.emissive.set(0x1a2840); + hullMat.emissiveIntensity = 0.35; + (coreGlow.material as THREE.MeshBasicMaterial).color.set(0x99ffff); + (underGlow.material as THREE.SpriteMaterial).color.set(0x44ddff); + for (const mat of rimLights) { + mat.emissive.set(0x00ffcc); + mat.emissiveIntensity = 1.8 + pulse * 0.8; + } + } + (underGlow.material as THREE.SpriteMaterial).opacity = (0.32 + pulse * 0.28) * GLOW_SCALE; + (coreGlow.material as THREE.MeshBasicMaterial).opacity = 0.7 + pulse * 0.28; + const halo = ufo.userData.halo as THREE.Sprite | undefined; + if (halo) { + (halo.material as THREE.SpriteMaterial).opacity = (0.2 + pulse * 0.18) * GLOW_SCALE; + } + } else { + const house = visual as THREE.Group; + const s = ob.radius * 0.24; + house.position.set(p.x, groundY, p.z); + house.rotation.y = ob.id * 0.93; + house.rotation.z = 0; + house.scale.set(s, s, s); + + const variant = ob.id % 3; + const wallMat = house.userData.wallMat as THREE.MeshStandardMaterial; + const roofMat = house.userData.roofMat as THREE.MeshStandardMaterial; + const trimMat = house.userData.trimMat as THREE.MeshStandardMaterial; + const windowMats = house.userData.windowMats as THREE.MeshStandardMaterial[]; + wallMat.color.set(HOUSE_WALLS[variant]); + roofMat.color.set(HOUSE_ROOFS[variant]); + trimMat.color.set(HOUSE_TRIM[variant]); + const glow = 0.45 + 0.2 * (0.5 + 0.5 * Math.sin(elapsed * 1.8 + ob.id)); + for (const mat of windowMats) { + mat.emissiveIntensity = glow; + } + } + }); + }; + + const syncLinks = (world: World): void => { + const active = new Set(); + for (const stats of world.links.values()) { + active.add(stats.key); + const a = world.drones[stats.a]; + const b = world.drones[stats.b]; + if (!a || !b) continue; + + let visual = linkLines.get(stats.key); + if (!visual) { + visual = createLinkVisual(); + linkLines.set(stats.key, visual); + scene.add(visual.line); + } + + const pa = worldToScene(a.pos.x, a.pos.y, fieldW); + const pb = worldToScene(b.pos.x, b.pos.y, fieldW); + pa.y = terrainHeight(pa.x, pa.z) + (a.alt ?? 22); + pb.y = terrainHeight(pb.x, pb.z) + (b.alt ?? 22); + visual.line.geometry.setFromPoints([pa, pb]); + + const hot = stats.rate > 20_000; + visual.mat.color.set(hot ? LINK_GREEN : LINK_BLUE); + visual.mat.opacity = 0.25 + stats.flash * 0.75; + visual.line.visible = visual.mat.opacity > 0.02; + } + + for (const [key, visual] of linkLines) { + if (active.has(key)) continue; + scene.remove(visual.line); + visual.line.geometry.dispose(); + visual.mat.dispose(); + linkLines.delete(key); + } + }; + + const animate = (): void => { + if (disposed) return; + raf = requestAnimationFrame(animate); + const dt = Math.min(clock.getDelta(), 0.05); + const elapsed = clock.getElapsedTime(); + groundMat.uniforms.uTime.value = elapsed; + (skyMesh.material as THREE.ShaderMaterial).uniforms.uTime.value = elapsed; + + if (sim?.enabled() && dt > 0) { + sim.step(dt); + } + const uiEvery = sim?.uiIntervalMs ?? 250; + if (sim?.onUiPulse && elapsed - lastUiPulse >= uiEvery / 1000) { + lastUiPulse = elapsed; + sim.onUiPulse(); + } + + const world = getWorld(); + syncField(areaWidth()); + syncRoads(world, fieldW, elapsed); + syncDrones(world); + syncBasePad(world, elapsed); + syncObstacles(world, elapsed); + syncLinks(world); + syncDetectionBeams(world, elapsed); + + for (const mesh of droneMeshes) { + mesh.children.forEach((child, idx) => { + if (idx < 2) return; + if (child instanceof THREE.Mesh && child.geometry.type === "TorusGeometry") { + child.rotation.z += 0.35; + } + }); + } + + controls.update(); + renderer.render(scene, camera); + }; + + resize(true); + const ro = new ResizeObserver(() => { + window.requestAnimationFrame(() => resize(false)); + }); + ro.observe(container); + animate(); + + return { + backend, + dispose: () => { + disposed = true; + cancelAnimationFrame(raf); + ro.disconnect(); + controls.dispose(); + renderer.dispose(); + scene.remove(roadGroup); + disposeRoadGroup(roadGroup); + disposePerimeter(perimeter); + ground.geometry.dispose(); + groundMat.dispose(); + linkLines.forEach((visual) => { + scene.remove(visual.line); + visual.line.geometry.dispose(); + visual.mat.dispose(); + }); + detectBeams.forEach((beam) => { + scene.remove(beam.mesh); + beam.mat.dispose(); + }); + beamPool.forEach((beam) => { + beam.mat.dispose(); + }); + beamGeo.dispose(); + droneMeshes.concat(dronePool).forEach((m) => { + m.traverse((o) => { + if (o instanceof THREE.Mesh) { + o.geometry.dispose(); + if (Array.isArray(o.material)) o.material.forEach((mat) => mat.dispose()); + else o.material.dispose(); + } + }); + }); + }, + }; +} + +export { fmtBytes, LINK_RANGE, type LinkStats, type World }; diff --git a/prototype/src/SwarmView3D.tsx b/prototype/src/SwarmView3D.tsx new file mode 100644 index 0000000..3b02f94 --- /dev/null +++ b/prototype/src/SwarmView3D.tsx @@ -0,0 +1,77 @@ +import { useEffect, useRef } from "react"; +import { mountSwarmScene, type RenderBackend } from "./SwarmScene"; +import type { World } from "./sim"; + +export interface SwarmView3DProps { + worldRef: React.MutableRefObject; + simEnabled: () => boolean; + onStep: (dt: number) => void; + onUiPulse: () => void; + onBackend: (backend: RenderBackend) => void; +} + +export function SwarmView3D({ + worldRef, + simEnabled, + onStep, + onUiPulse, + onBackend, +}: SwarmView3DProps): JSX.Element { + const containerRef = useRef(null); + const canvasRef = useRef(null); + const simRef = useRef({ simEnabled, onStep }); + simRef.current = { simEnabled, onStep }; + + useEffect(() => { + const container = containerRef.current; + const canvas = canvasRef.current; + if (!container || !canvas) return; + + let handle: { dispose(): void; backend: RenderBackend } | null = null; + let cancelled = false; + + void mountSwarmScene(container, canvas, () => worldRef.current, { + enabled: () => simRef.current.simEnabled(), + step: (dt) => simRef.current.onStep(dt), + onUiPulse, + uiIntervalMs: 250, + }).then((h) => { + if (cancelled) { + h.dispose(); + return; + } + handle = h; + onBackend(h.backend); + }); + + return () => { + cancelled = true; + handle?.dispose(); + }; + }, [worldRef, onUiPulse, onBackend]); + + return ( +
+ +
+ ); +} + +const styles: Record = { + viewport: { + flex: 1, + minWidth: 0, + minHeight: 0, + position: "relative", + overflow: "hidden", + }, + canvas: { + position: "absolute", + inset: 0, + width: "100%", + height: "100%", + display: "block", + touchAction: "none", + outline: "none", + }, +}; diff --git a/prototype/src/live.ts b/prototype/src/live.ts index fe723b4..f7c0325 100644 --- a/prototype/src/live.ts +++ b/prototype/src/live.ts @@ -79,16 +79,27 @@ function linkKey(a: number, b: number): string { /** Convert live poses into the World shape the renderer already speaks. */ export function toLiveWorld(poses: LivePose[], prev: World | null, dtS: number): World { const k = fitScale(poses); - const drones = poses.map((p, i) => ({ - id: i, - name: p.id, - pos: { x: p.x * k, y: p.y * k }, - vel: { x: 0, y: 0 }, - heading: (p.yawDeg * Math.PI) / 180, - waypoint: 0, - battery: p.battery ?? 100, - channel: 0, - })); + const drones = poses.map((p, i) => { + const t = (prev?.t ?? 0) + dtS; + const band = 16 + (i % 6) * 4.5; + const alt = + band + + Math.sin(t * 0.62 + i * 0.91) * 4.5 + + Math.sin(p.x * 0.011 + t * 0.45) * 2; + const altVel = Math.cos(t * 0.62 + i * 0.91) * 2.8; + return { + id: i, + name: p.id, + pos: { x: p.x * k, y: p.y * k }, + vel: { x: 0, y: 0 }, + heading: (p.yawDeg * Math.PI) / 180, + waypoint: 0, + battery: p.battery ?? 100, + channel: 0, + alt, + altVel, + }; + }); // Every in-range pair exchanges pose broadcasts; accumulate totals so the // panel keeps its meaning between polls. @@ -99,6 +110,7 @@ export function toLiveWorld(poses: LivePose[], prev: World | null, dtS: number): const dist = Math.hypot( drones[i].pos.x - drones[j].pos.x, drones[i].pos.y - drones[j].pos.y, + (drones[i].alt ?? 0) - (drones[j].alt ?? 0), ); if (dist > LINK_RANGE) continue; const key = linkKey(i, j); diff --git a/prototype/src/search.ts b/prototype/src/search.ts new file mode 100644 index 0000000..31feb34 --- /dev/null +++ b/prototype/src/search.ts @@ -0,0 +1,50 @@ +export interface SearchHit { + url: string; + title: string; + content: string; + score: number; + engines: string[]; +} + +export interface SearchResponse { + query: string; + results: SearchHit[]; +} + +interface RawSearchResult { + url?: string; + title?: string; + content?: string; + score?: number; + engines?: string[]; +} + +interface RawSearchResponse { + query?: string; + results?: RawSearchResult[]; +} + +export async function searchWeb(query: string): Promise { + const q = query.trim(); + if (!q) return { query: q, results: [] }; + + const params = new URLSearchParams({ q, format: "json" }); + const res = await fetch(`/api/search?${params.toString()}`); + if (!res.ok) throw new Error(`search responded ${res.status}`); + + const data = (await res.json()) as RawSearchResponse; + const results = (data.results ?? []) + .filter((r): r is RawSearchResult & { url: string; title: string } => + Boolean(r.url && r.title), + ) + .map((r) => ({ + url: r.url, + title: r.title, + content: (r.content ?? "").slice(0, 220), + score: r.score ?? 0, + engines: r.engines ?? [], + })) + .slice(0, 12); + + return { query: data.query ?? q, results }; +} diff --git a/prototype/src/sim.ts b/prototype/src/sim.ts index 6e18131..2414918 100644 --- a/prototype/src/sim.ts +++ b/prototype/src/sim.ts @@ -16,6 +16,30 @@ export interface Drone { 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; // 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 { @@ -24,6 +48,12 @@ export interface Obstacle { 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 { @@ -37,6 +67,46 @@ export interface LinkStats { 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 = { + 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[]; @@ -44,8 +114,20 @@ export interface World { links: Map; 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), @@ -54,6 +136,76 @@ 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; @@ -62,6 +214,49 @@ 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 { @@ -96,28 +291,781 @@ function crossRoute(): Vec[] { { x: 150, y: AREA - 150 }, ]; } -function routeOf(d: Drone): Vec[] { +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, + 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; -function spawnTransit(id: number): Obstacle { - const speed = 26 + rnd() * 30; - const alongX = 120 + rnd() * (areaX - 240); - const alongY = 120 + rnd() * (AREA - 240); - const skew = (rnd() - 0.5) * speed * 0.9; // diagonal-ish crossings - const edge = Math.floor(rnd() * 4); - const table: { pos: Vec; vel: Vec }[] = [ - { pos: { x: alongX, y: -TRANSIT_MARGIN + 20 }, vel: { x: skew, y: speed } }, - { pos: { x: alongX, y: AREA + TRANSIT_MARGIN - 20 }, vel: { x: skew, y: -speed } }, - { pos: { x: -TRANSIT_MARGIN + 20, y: alongY }, vel: { x: speed, y: skew } }, - { pos: { x: areaX + TRANSIT_MARGIN - 20, y: alongY }, vel: { x: -speed, y: skew } }, +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(); + 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 }, ]; - const { pos, vel } = table[edge]; - return { id, pos, radius: 22 + rnd() * 16, moving: true, vel }; + 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 ( @@ -128,65 +1076,572 @@ function outOfTransit(ob: Obstacle): boolean { ); } -export function makeWorld(droneCount: number, aspect = 1): World { +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 angle = (i / droneCount) * Math.PI * 2; - return { + 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: { - x: areaX / 2 + Math.cos(angle) * (150 + rnd() * 120), - y: AREA / 2 + Math.sin(angle) * (150 + rnd() * 120), - }, + pos: spawn.pos, vel: { x: 0, y: 0 }, - heading: angle, + heading: spawn.heading, waypoint: i % 4, - battery: 90 + rnd() * 10, + 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[] = [ - { id: 0, pos: { x: areaX * 0.34, y: 420 }, radius: 60, moving: false, vel: { x: 0, y: 0 } }, - { id: 1, pos: { x: areaX * 0.7, y: 260 }, radius: 45, moving: false, vel: { x: 0, y: 0 } }, - { id: 2, pos: { x: areaX * 0.56, y: 720 }, radius: 70, moving: false, vel: { x: 0, y: 0 } }, - spawnTransit(3), - spawnTransit(4), - spawnTransit(5), + ...houses, + spawnTransit(houses.length), + spawnTransit(houses.length + 1), + spawnTransit(houses.length + 2), ]; - return { t: 0, drones, obstacles, links: new Map(), totalBytes: 0, broadcasts: 0 }; + 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 steer(d: Drone, world: World, dt: number): Drone { - const route = routeOf(d); - const wp = route[(d.waypoint + d.id) % route.length]; - let ax = wp.x - d.pos.x; - let ay = wp.y - d.pos.y; - const wpDist = Math.hypot(ax, ay); - let waypoint = d.waypoint; - if (wpDist < 90) waypoint = (d.waypoint + 1) % route.length; - ax /= wpDist || 1; - ay /= wpDist || 1; +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); +} - // Separation from peers - 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; +function cloneAware(m?: Map): Map { + 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, from: Map): 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 } { + const awareList = drones.map((d) => cloneAware(d.aware)); + const intelLinks = new Set(); + + 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)); + } } } - // Obstacle avoidance - for (const ob of world.obstacles) { + + 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; + steerMetrics?: Map; +} + +function buildEscortDuty(drones: Drone[], obstacles: Obstacle[], t: number): Map { + const duty = new Map(); + const used = new Set(); + + 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); @@ -198,36 +1653,155 @@ function steer(d: Drone, world: World, dt: number): Drone { } } - const mag = Math.hypot(ax, ay) || 1; - const vx = d.vel.x * 0.85 + (ax / mag) * CRUISE * 0.15; - const vy = d.vel.y * 0.85 + (ay / mag) * CRUISE * 0.15; - const heading = Math.atan2(vy, vx); + 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: { x: vx, y: vy }, + vel: landedNow ? { x: 0, y: 0 } : { x: vx, y: vy }, heading, - pos: { - 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 * 0.05), + 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 drones = world.drones.map((d) => steer(d, world, dt)); const obstacles = world.obstacles.map((ob) => { if (!ob.moving) return ob; - const moved = { - ...ob, - pos: { x: ob.pos.x + ob.vel.x * dt, y: ob.pos.y + ob.vel.y * dt }, - }; - // Once a transit object leaves the area, respawn it at a random edge - return outOfTransit(moved) ? spawnTransit(ob.id) : moved; + 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() }; + + 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(); + 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; @@ -237,7 +1811,11 @@ export function tick(world: World, dt: number): World { const a = drones[l.a]; const b = drones[l.b]; const inRange = - a && b && Math.hypot(a.pos.x - b.pos.x, a.pos.y - b.pos.y) <= LINK_RANGE; + 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; @@ -247,10 +1825,13 @@ export function tick(world: World, dt: number): World { for (let i = 0; i < drones.length; i++) { for (let j = i + 1; j < drones.length; j++) { - const dist = Math.hypot( - drones[i].pos.x - drones[j].pos.x, - drones[i].pos.y - drones[j].pos.y, - ); + 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 = @@ -265,6 +1846,10 @@ export function tick(world: World, dt: number): World { 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; @@ -283,7 +1868,27 @@ export function tick(world: World, dt: number): World { } } - return { t: world.t + dt, drones, obstacles, links, totalBytes, broadcasts }; + 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 { diff --git a/prototype/vite.config.ts b/prototype/vite.config.ts index 081c8d9..c76ae9a 100644 --- a/prototype/vite.config.ts +++ b/prototype/vite.config.ts @@ -3,4 +3,24 @@ import react from "@vitejs/plugin-react"; export default defineConfig({ plugins: [react()], + server: { + port: 5173, + proxy: { + "/api/search": { + target: "https://search.produktor.io", + changeOrigin: true, + rewrite: (p) => p.replace(/^\/api\/search/, "/search"), + }, + }, + }, + preview: { + port: 5173, + proxy: { + "/api/search": { + target: "https://search.produktor.io", + changeOrigin: true, + rewrite: (p) => p.replace(/^\/api\/search/, "/search"), + }, + }, + }, });