index.html
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>SWARM — Boid-Testground</title>
<script type="importmap">{"imports":{"three":"https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js"}}</script>
<style>
:root{--bg:#121214;--panel:#17171a;--field:#1f1f23;--line:#2a2a2f;--ink:#d8d8dc;--mut:#7e7e88;--acc:#d9627f;--stage:min(56vw,calc(100vh - 112px));
--c-sense:#56708a;--c-event:#8a5a66;--c-math:#6c6c74;--c-steer:#587a64;--c-act:#8a7650;--c-debug:#70658a;--c-user:#82804f}
*{box-sizing:border-box}
html,body{margin:0;height:100%;background:var(--bg);color:var(--ink);font:12px/1.4 ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;overflow:hidden}
.mono,input[type=number],.val,textarea,#hud,#gStat{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
.mut{color:var(--mut)}
button,select,input,textarea{font:inherit;color:inherit;outline:none;color-scheme:dark}
button{background:transparent;border:1px solid #36363c;border-radius:3px;padding:2px 8px;cursor:pointer;line-height:1.5}
button:hover{border-color:#8a8a94}
button.on{background:var(--ink);border-color:var(--ink);color:#111}
select,input[type=number],input[type=text],textarea{border:1px solid #36363c;border-radius:3px;background:var(--field);padding:1px 4px}
select:focus,input:focus,textarea:focus{border-color:#8a8a94}
input[type=range]{accent-color:#b8b8c0;height:12px;vertical-align:middle}
input[type=color]{width:22px;height:16px;padding:0;border:1px solid #36363c;border-radius:3px;background:var(--field)}
#app{display:grid;grid-template-rows:34px 1fr;height:100vh}
#top{display:flex;align-items:center;gap:5px;padding:0 8px;border-bottom:1px solid var(--line);white-space:nowrap;overflow:hidden;background:var(--panel)}
#top b{letter-spacing:.16em;margin-right:6px;font-weight:600}
.sp{flex:1}
.g{display:flex;align-items:center;gap:3px;color:var(--mut)}
.g input{width:40px}
#main{display:grid;grid-template-columns:var(--stage) 1fr;min-height:0}
#main.collapsed{grid-template-columns:1fr 0}
#stageCol{display:flex;flex-direction:column;min-width:0;min-height:0;border-right:1px solid var(--line);background:var(--panel)}
#stageWrap{position:relative;flex:1;min-height:0;cursor:crosshair;overflow:hidden}
#gl{position:absolute;inset:0;width:100%;height:100%;display:block}
#lbl{position:absolute;inset:0;pointer-events:none;overflow:hidden;font:10px ui-monospace,monospace}
#lbl span{position:absolute;transform:translate(9px,-15px);color:#111;background:#fffd;padding:0 2px;border-radius:2px;white-space:nowrap}
#rect{position:absolute;border:1px solid var(--acc);background:#d9627f18;display:none;pointer-events:none}
#hud{position:absolute;left:8px;top:6px;color:#666;font-size:10px;pointer-events:none}
.bar{display:flex;align-items:center;gap:6px;padding:5px 8px;border-top:1px solid var(--line);flex-wrap:wrap;min-height:30px}
#side{display:flex;flex-direction:column;min-width:0;min-height:0;overflow:hidden;background:var(--panel)}
#tabs{display:flex;gap:2px;padding:4px 8px;border-bottom:1px solid var(--line)}
#tabs button{border-color:transparent;color:var(--mut)}
#tabs button:hover{color:var(--ink)}
#tabs button.on{background:#2a2a30;color:var(--ink);border-color:transparent}
.pane{display:none;flex:1;min-height:0;overflow:auto;padding:10px 14px}
.pane.on{display:block}
#pGraph{padding:0}
#pGraph.on{display:flex;flex-direction:column}
#gbar{display:flex;gap:4px;align-items:center;padding:5px 8px;border-bottom:1px solid var(--line);flex-wrap:wrap}
.bt{border:1px solid #36363c;border-radius:3px;padding:1px 8px;cursor:pointer;display:inline-flex;gap:6px;align-items:center;color:var(--mut)}
.bt:hover{color:var(--ink)}
.bt.on{background:var(--ink);color:#111;border-color:var(--ink)}
.bt i{font-style:normal;opacity:.6}.bt i:hover{opacity:1}
#graphView{position:relative;flex:1;overflow:hidden;background:var(--bg);background-image:radial-gradient(#2b2b30 1px,transparent 1px);background-size:20px 20px;cursor:grab}
#graphView.pan{cursor:grabbing}
#gWorld{position:absolute;left:0;top:0;transform-origin:0 0}
#gWires{position:absolute;left:0;top:0;width:1px;height:1px;overflow:visible;pointer-events:none}
#gWires path{fill:none;stroke:#6d6d76;stroke-width:1.5;pointer-events:stroke}
#gWires path.ev{stroke:var(--acc);stroke-dasharray:4 3}
#gWires path:hover{stroke:#fff;stroke-width:3}
#gWires path.tmp{stroke:#fff;pointer-events:none}
.node{position:absolute;width:196px;background:var(--c,#666);border:1px solid #0006;border-radius:5px;font-size:11px;user-select:none;color:#f1f1f3;box-shadow:0 2px 8px #0005}
.node.sel{box-shadow:0 0 0 2px #e8e8ec}
.node.fire>.nh{background:var(--acc)!important}
.node.err{border-color:var(--acc);border-style:dashed}
.nh{display:flex;justify-content:space-between;align-items:center;padding:3px 8px;background:#0003;cursor:move;font-weight:600;border-radius:4px 4px 0 0}
.nh .nx{font-style:normal;cursor:pointer;opacity:.45;padding:0 2px}.nh .nx:hover{opacity:1}
.cat-sense{--c:var(--c-sense)}.cat-event{--c:var(--c-event)}.cat-math{--c:var(--c-math)}.cat-steer{--c:var(--c-steer)}.cat-act{--c:var(--c-act)}.cat-debug{--c:var(--c-debug)}.cat-user{--c:var(--c-user)}
.nb{padding:2px 0 3px}
.r{position:relative;display:flex;justify-content:space-between;align-items:center;min-height:20px;padding:0 10px;gap:6px}
.r .l,.r .rr{display:flex;align-items:center;gap:4px;min-width:0}
.r .rr{justify-content:flex-end}
.pt{position:absolute;top:50%;width:10px;height:10px;margin-top:-5px;border:1.5px solid #d0d0d6;border-radius:50%;background:#1a1a1d;cursor:crosshair}
.pt.in{left:-6px}.pt.out{right:-6px}
.pt:hover,.pt.hot{background:#fff}
.pt[data-t=v]{border-color:#9bbcf0}.pt[data-t=l]{border-color:#9be0b4}.pt[data-t=b]{border-color:#f2a9b8}.pt[data-t=c]{border-color:#f0cd85}.pt[data-t=o]{border-color:#cdb0f2}
.pl{color:#ffffffd0}
.val{font-size:10px;color:#fff;background:#0004;border-radius:2px;padding:0 3px;max-width:74px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.val:empty{display:none}
.node input[type=number],.node select,.node input[type=text]{background:#0000004a;border-color:#0000;color:#fff}
.nb input[type=number]{width:48px;font-size:10px;padding:0 2px}
.nb select,.nb input[type=text]{max-width:96px;font-size:10px;padding:0 2px}
.nb input[type=text].wide{width:100%;max-width:none}
.node .mut{color:#ffffff90}
.ex{width:7px;height:7px;border-radius:50%;border:1px solid #ffffff80;cursor:pointer;flex:none}
.ex.on{background:#fff;border-color:#fff}
#addMenu{position:absolute;z-index:20;width:240px;background:#1b1b1f;border:1px solid #3a3a42;border-radius:5px;display:none;box-shadow:0 8px 28px #000a}
#addMenu input{width:100%;border:0;border-bottom:1px solid var(--line);border-radius:5px 5px 0 0;padding:6px 8px;background:transparent}
#addList{max-height:300px;overflow:auto}
#addList div{padding:3px 8px 3px 10px;cursor:pointer;display:flex;justify-content:space-between;border-left:4px solid var(--c,#666)}
#addList div span{color:var(--mut);font-size:10px}
#addList div.on,#addList div:hover{background:#2c2c33}
#legend{position:absolute;right:8px;top:6px;display:flex;gap:4px;flex-wrap:wrap;justify-content:flex-end;max-width:70%;pointer-events:none}
#legend span{pointer-events:auto;font-size:10px;padding:0 6px;border-radius:8px;background:var(--c);color:#f1f1f3;opacity:.9}
#gStat{position:absolute;left:8px;bottom:6px;font-size:10px;color:var(--mut);pointer-events:none;max-width:90%}
#gStat.bad{color:var(--acc)}
#tip{position:fixed;z-index:100;display:none;max-width:290px;padding:8px 10px;background:#0c0c0e;border:1px solid #3a3a42;border-radius:5px;font-size:11px;line-height:1.45;color:#d8d8dc;pointer-events:none;box-shadow:0 6px 20px #000a}
#tip b{display:block;font-size:12px;margin-bottom:3px}
#tip i{display:block;font-style:normal;margin-top:5px;color:var(--mut);font-size:10px}
#tip u{text-decoration:none;display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:5px;background:var(--c)}
h4{margin:14px 0 4px;font-size:10px;letter-spacing:.1em;text-transform:uppercase;color:var(--mut);font-weight:600}
h4:first-child{margin-top:0}
.row{display:flex;align-items:center;gap:8px;margin:4px 0}
.row>label{width:118px;color:#a8a8b0;flex:none}
.row input[type=range]{flex:1;max-width:220px}
.row .v{width:46px;text-align:right;color:var(--mut)}
.chk{display:flex;align-items:center;gap:6px;margin:3px 0}
pre{margin:4px 0;white-space:pre-wrap;word-break:break-word;font:10px/1.4 ui-monospace,monospace;color:#a8a8b0}
#llmLog{border:1px solid var(--line);border-radius:3px;padding:6px 8px;min-height:120px;max-height:46vh;overflow:auto;margin-top:8px;white-space:pre-wrap;font:11px/1.45 ui-monospace,monospace}
#llmLog .u{color:var(--mut)}#llmLog .e{color:var(--acc)}
textarea#llmIn{width:100%;height:84px;resize:vertical}
::-webkit-scrollbar{width:8px;height:8px}::-webkit-scrollbar-thumb{background:#34343a;border-radius:4px}::-webkit-scrollbar-track{background:transparent}
</style>
</head>
<body>
<div id="tip"></div>
<div id="app">
<header id="top">
<b>SWARM</b>
<button id="bPlay" class="on" title="Space">❚❚</button><button id="bStep" title="S">⏭</button>
<select id="selSpeed" title="Simulationsgeschwindigkeit"><option value="0.25">×¼</option><option value="0.5">×½</option><option value="1" selected>×1</option><option value="2">×2</option><option value="5">×5</option><option value="10">×10</option></select>
<span class="mut">Boids</span><input id="inN" type="number" min="1" max="400" value="9" style="width:46px"><button id="bSpawn" title="Alle Boids neu verteilen">Neu</button>
<span id="fps" class="mut mono"></span>
<span class="sp"></span>
<select id="allBh" title="Alle Boids in ein Verhalten schicken"></select><button data-gm="penup" title="Alle Stifte heben">Stift ↑</button><button data-gm="pendown" title="Alle Stifte senken">Stift ↓</button><button data-gm="sleep" title="Alle schlafen legen">Schlaf</button><button data-gm="wake" title="Alle wecken">Wecken</button><button data-gm="scatter" title="Alle zufällig neu verteilen">Streuen</button><button data-gm="clearink" title="Gemalte Spuren löschen">Tinte ✕</button>
<span class="g">G1<input type="range" min="0" max="1" step="0.01" value="0" data-g="0"></span>
<span class="g">G2<input type="range" min="0" max="1" step="0.01" value="0" data-g="1"></span>
<span class="g">G3<input type="range" min="0" max="1" step="0.01" value="0" data-g="2"></span>
<span class="g">G4<input type="range" min="0" max="1" step="0.01" value="0" data-g="3"></span>
<button id="bSide" title="Panel ein-/ausklappen">⇥</button>
</header>
<div id="main">
<div id="stageCol">
<div id="stageWrap"><canvas id="gl"></canvas><div id="lbl"></div><div id="rect"></div><div id="hud"></div></div>
<div class="bar" id="toolBar">
<button data-tool="select" class="on">Wählen</button><button data-tool="circ">○ Hindernis</button><button data-tool="rect">□ Hindernis</button><button data-tool="erase">Löschen</button>
<span class="sp"></span><span class="mut">Spur verblasst</span><input id="rFade" type="range" min="0" max="120" step="1" value="0" style="width:90px"><span id="vFade" class="mono mut" style="width:34px">aus</span>
</div>
<div class="bar" id="selBar"></div>
</div>
<aside id="side">
<div id="tabs"><button data-pane="pGraph" class="on">Verhalten</button><button data-pane="pBoid">Boid</button><button data-pane="pDebug">Debug</button><button data-pane="pSim">Sim</button><button data-pane="pLLM">LLM</button></div>
<section class="pane on" id="pGraph">
<div id="gbar"><span id="bhTabs" style="display:contents"></span><button id="bhAdd" title="Neues Verhalten">+</button><span class="sp"></span><button id="gUndo" title="Ctrl+Z">↶</button><button id="gFit" title="Alles einpassen">einpassen</button><select id="libSel"><option value="">Bibliothek…</option></select><button id="libSave" title="Aktuelles Verhalten in die Bibliothek legen">→ Bibliothek</button></div>
<div id="graphView">
<div id="gWorld"><svg id="gWires" xmlns="http://www.w3.org/2000/svg"></svg></div>
<div id="addMenu"><input type="text" placeholder="Node suchen…"><div id="addList"></div></div>
<div id="legend"></div><div id="gStat"></div>
</div>
</section>
<section class="pane" id="pBoid"><div id="boidInfo"></div></section>
<section class="pane" id="pDebug">
<h4>Layer</h4>
<div class="row"><label>Umfang</label><select id="dScope"><option value="sel">nur Auswahl</option><option value="all">alle</option><option value="off">aus</option></select></div>
<div id="dChecks"></div>
<h4>Graph</h4>
<div class="chk"><input type="checkbox" id="dLive" checked><label for="dLive">Live-Werte der Auswahl in den Nodes</label></div>
</section>
<section class="pane" id="pSim"><div id="simUI"></div></section>
<section class="pane" id="pLLM">
<div class="row"><label>Anbieter</label><select id="llmProv"><option value="kitegg">KITEGG</option><option value="deepseek">DeepSeek</option></select><input type="text" id="llmModel" placeholder="Modell (leer = Standard)" style="flex:1"><button id="llmModels">Modelle</button></div>
<div class="row"><label>Modus</label><select id="llmMode"><option value="edit">Verhalten bearbeiten</option><option value="explain">Erklären / Debuggen</option></select><input type="text" id="llmGate" placeholder="Gate-Passwort (optional)" style="flex:1"></div>
<textarea id="llmIn" placeholder="z. B.: Wer allein ist, sucht den nächsten Boid mit roter LED und folgt ihm. Wer drei Nachbarn hat, malt eine Spirale."></textarea>
<div class="row"><button id="llmSend">senden</button><button id="llmUndo">Änderung zurück</button><span class="mut" id="llmState"></span></div>
<div id="llmLog"></div>
</section>
</aside>
</div>
</div>
<script type="module">
import * as THREE from 'three';
const $=s=>document.querySelector(s), $$=s=>[...document.querySelectorAll(s)];
const TAU=Math.PI*2, R=0.015, HALF=0.5, CAP=512, DT=1/60;
/* ================= Helfer ================= */
const clamp=(x,a,b)=>x<a?a:x>b?b:x;
const wrapA=a=>{a=(a+Math.PI)%TAU;if(a<0)a+=TAU;return a-Math.PI};
const V2=(x=0,y=0)=>({x,y});
const isV=o=>o!==null&&typeof o==='object'&&'x' in o;
const isC=o=>o!==null&&typeof o==='object'&&'r' in o;
const vlen=v=>Math.hypot(v.x,v.y);
const vlim=(v,m)=>{const l=Math.hypot(v.x,v.y);return l>m&&l>0?{x:v.x/l*m,y:v.y/l*m}:v};
const vmul=(v,k)=>({x:v.x*k,y:v.y*k});
const vnorm=v=>{const l=Math.hypot(v.x,v.y);return l>1e-9?{x:v.x/l,y:v.y/l}:{x:0,y:0}};
const truthy=v=>!!v;
function mulberry32(a){return()=>{a|=0;a=a+0x6D2B79F5|0;let t=Math.imul(a^a>>>15,1|a);t=t+Math.imul(t^t>>>7,61|t)^t;return((t^t>>>14)>>>0)/4294967296}}
const hash=n=>{n=Math.imul(n^(n>>>15),0x85ebca6b);n=Math.imul(n^(n>>>13),0xc2b2ae35);return((n^(n>>>16))>>>0)/4294967296};
function noise1(x,seed){const i=Math.floor(x),f=x-i,u=f*f*(3-2*f);return(hash(i*7919+seed)*(1-u)+hash((i+1)*7919+seed)*u)*2-1}
function hsv(h,s,v){h=(((h%360)+360)%360)/60;const c=v*s,x=c*(1-Math.abs(h%2-1)),m=v-c;let r=0,g=0,b=0;
if(h<1){r=c;g=x}else if(h<2){r=x;g=c}else if(h<3){g=c;b=x}else if(h<4){g=x;b=c}else if(h<5){r=x;b=c}else{r=c;b=x}return{r:r+m,g:g+m,b:b+m}}
function hueOf(c){const mx=Math.max(c.r,c.g,c.b),mn=Math.min(c.r,c.g,c.b),d=mx-mn;if(d<1e-6)return 0;let h;
if(mx===c.r)h=((c.g-c.b)/d)%6;else if(mx===c.g)h=(c.b-c.r)/d+2;else h=(c.r-c.g)/d+4;h*=60;return h<0?h+360:h}
const hexCache=new Map();
function hex2c(s){if(isC(s))return s;let c=hexCache.get(s);if(!c){const m=/^#?([0-9a-f]{6})$/i.exec(s||'');const n=m?parseInt(m[1],16):0x111111;c={r:(n>>16&255)/255,g:(n>>8&255)/255,b:(n&255)/255};hexCache.set(s,c)}return c}
const c2hex=c=>'#'+[c.r,c.g,c.b].map(v=>Math.round(clamp(v,0,1)*255).toString(16).padStart(2,'0')).join('');
const H={V2,vlen,vlim,vmul,vnorm,clamp,wrapA,hsv,hueOf,hex2c,noise1,isV,isC,TAU};
/* ================= Welt ================= */
const W={cap:CAP,n:0,t:0,tick:0,speed:1,running:true,
x:new Float32Array(CAP),y:new Float32Array(CAP),vx:new Float32Array(CAP),vy:new Float32Array(CAP),h:new Float32Array(CAP),sp:new Float32Array(CAP),
B:[],G:[0,0,0,0],mouse:{x:0,y:0,in:false},obst:[],queue:[],plans:{},sel:new Set(),watch:-1,snap:{},dbgTick:false,
sim:{drive:'free',maxSpeed:12,maxAccel:40,maxTurn:6,friction:0.5,rest:0.3,spread:0,latency:3,loss:0,fade:0,penW:1.2},
init:{bh:'Flocken',n:9},msgDbg:[],runErr:null};
let rng=mulberry32(7);W.rand=()=>rng();W.seed=s=>{rng=mulberry32(s)};
W.st=(b,s)=>b.st[s.id]||(b.st[s.id]={});
const mS=b=>W.sim.maxSpeed*0.01*(W.sim.spread>0?b.k:1);
const mA=b=>W.sim.maxAccel*0.01*(W.sim.spread>0?b.k:1);
/* ---- Spatial Hash (Zelle 5 cm) ---- */
const CELL=0.05,GN=Math.ceil(1/CELL)+2;
const gHead=new Int32Array(GN*GN),gNext=new Int32Array(CAP);
const cIdx=v=>clamp(Math.floor((v+HALF)/CELL)+1,0,GN-1);
function gridBuild(){gHead.fill(-1);for(let i=0;i<W.n;i++){const c=cIdx(W.y[i])*GN+cIdx(W.x[i]);gNext[i]=gHead[c];gHead[c]=i}}
W.query=(i,r,fov=360)=>{const out=[],x=W.x[i],y=W.y[i],r2=r*r;
const c0=cIdx(x-r),c1=cIdx(x+r),d0=cIdx(y-r),d1=cIdx(y+r);
const useF=fov<359.5,hx=Math.cos(W.h[i]),hy=Math.sin(W.h[i]),ct=Math.cos(fov*Math.PI/360);
for(let cy=d0;cy<=d1;cy++)for(let cx=c0;cx<=c1;cx++)for(let j=gHead[cy*GN+cx];j>=0;j=gNext[j]){
if(j===i)continue;const dx=W.x[j]-x,dy=W.y[j]-y,d2=dx*dx+dy*dy;if(d2>r2)continue;
if(useF){const d=Math.sqrt(d2);if(d>1e-9&&(dx*hx+dy*hy)/d<ct)continue}out.push(j)}
return out};
/* ---- Ray gegen Wände / Hindernisse ---- */
function rayCast(ox,oy,dx,dy,maxL){
let t=maxL,nx=0,ny=0,hit=false;
const tryT=(tt,ax,ay)=>{if(tt>=0&&tt<t){t=tt;nx=ax;ny=ay;hit=true}};
if(dx>1e-9)tryT((HALF-ox)/dx,-1,0);else if(dx<-1e-9)tryT((-HALF-ox)/dx,1,0);
if(dy>1e-9)tryT((HALF-oy)/dy,0,-1);else if(dy<-1e-9)tryT((-HALF-oy)/dy,0,1);
for(const o of W.obst){
if(o.t==='c'){const fx=ox-o.x,fy=oy-o.y,b2=fx*dx+fy*dy,c=fx*fx+fy*fy-o.r*o.r,disc=b2*b2-c;
if(disc>=0){const s=Math.sqrt(disc);let tt=-b2-s;if(tt<0)tt=-b2+s;
if(tt>=0&&tt<t){const px=ox+dx*tt-o.x,py=oy+dy*tt-o.y,pl=Math.hypot(px,py)||1;tryT(tt,px/pl,py/pl)}}}
else{let tmin=-Infinity,tmax=Infinity,nxm=0,nym=0,ok=true;
if(Math.abs(dx)<1e-9){if(ox<o.x0||ox>o.x1)ok=false}else{let a=(o.x0-ox)/dx,b=(o.x1-ox)/dx,na=-1;if(a>b){[a,b]=[b,a];na=1}if(a>tmin){tmin=a;nxm=na;nym=0}if(b<tmax)tmax=b}
if(ok){if(Math.abs(dy)<1e-9){if(oy<o.y0||oy>o.y1)ok=false}else{let a=(o.y0-oy)/dy,b=(o.y1-oy)/dy,na=-1;if(a>b){[a,b]=[b,a];na=1}if(a>tmin){tmin=a;nxm=0;nym=na}if(b<tmax)tmax=b}}
if(ok&&tmin<=tmax&&tmin>=0)tryT(tmin,nxm,nym)}}
return{hit,t,nx,ny}}
W.rayCast=rayCast;
/* ---- Tinte: CPU-Raster (Sensor) + Segment-Puffer (GPU) ---- */
const SEGCAP=16384,segs=new Float32Array(SEGCAP*8);let segN=0;
const INKN=128,ink=new Uint8Array(INKN*INKN);
const inkAt=(x,y)=>{const ix=Math.floor((x+HALF)*INKN),iy=Math.floor((y+HALF)*INKN);return ix<0||iy<0||ix>=INKN||iy>=INKN?0:ink[iy*INKN+ix]};
const inkMark=(x,y)=>{const ix=Math.floor((x+HALF)*INKN),iy=Math.floor((y+HALF)*INKN);if(ix>=0&&iy>=0&&ix<INKN&&iy<INKN)ink[iy*INKN+ix]=255};
W.inkAt=inkAt;
let inkClearReq=true;
function clearInk(){ink.fill(0);segN=0;inkClearReq=true}
function penDraw(b,k){const p=b.pen,x=W.x[k],y=W.y[k];
if(!p.down||b.sleep>0){p.was=false;return}
if(!p.was){p.px=x;p.py=y;p.was=true;inkMark(x,y)}
const dx=x-p.px,dy=y-p.py,d=Math.hypot(dx,dy);if(d<0.0004)return;
if(segN<SEGCAP){const o=segN*8;segs[o]=p.px;segs[o+1]=p.py;segs[o+2]=x;segs[o+3]=y;segs[o+4]=p.c.r;segs[o+5]=p.c.g;segs[o+6]=p.c.b;segs[o+7]=p.w;segN++}
const n=Math.ceil(d/0.003);for(let s=1;s<=n;s++)inkMark(p.px+dx*s/n,p.py+dy*s/n);
p.px=x;p.py=y}
let fadeAcc=0,fadeStep=0;
/* ---- Debug-Puffer ---- */
const DMAX=60000,D={on:{vel:false,steer:true,rad:true,nbr:true,tgt:true,ray:true,msg:true,id:true,probe:true},scope:'sel',
pos:new Float32Array(DMAX*3),col:new Float32Array(DMAX*3),n:0,nodeN:0};
const DCOL={vel:[.16,.44,1],steer:[1,.23,.42],rad:[.7,.7,.7],nbr:[0,.7,.42],tgt:[1,.58,0],ray:[1,.58,0],msg:[.6,.19,1],probe:[.1,.1,.1]};
W.dbgOn=(b,k)=>W.dbgTick&&D.scope!=='off'&&D.on[k]&&(D.scope==='all'||b.sel);
function dseg(x1,y1,x2,y2,c){if(D.n+2>DMAX)return;const k=D.n*3,cc=DCOL[c]||DCOL.probe;D.pos[k]=x1;D.pos[k+1]=y1;D.pos[k+2]=0.02;D.pos[k+3]=x2;D.pos[k+4]=y2;D.pos[k+5]=0.02;
D.col.set(cc,k);D.col.set(cc,k+3);D.n+=2}
W.dl=(x1,y1,x2,y2,c)=>{dseg(x1,y1,x2,y2,c);D.nodeN=D.n};
W.dc=(x,y,r,c,n=28)=>{for(let k=0;k<n;k++){const a=k/n*TAU,b=(k+1)/n*TAU;dseg(x+Math.cos(a)*r,y+Math.sin(a)*r,x+Math.cos(b)*r,y+Math.sin(b)*r,c)}D.nodeN=D.n};
W.dx=(x,y,c,s=0.008)=>{dseg(x-s,y,x+s,y,c);dseg(x,y-s,x,y+s,c);D.nodeN=D.n};
W.dMsg=(a,c)=>{if(D.on.msg&&D.scope!=='off')W.msgDbg.push({a,c,t:W.t})};
/* ================= Boids ================= */
function mkBoid(i){return{i,id:i,bh:'',bt:0,enter:true,st:{},vars:{},inbox:{},pen:{down:false,c:{r:.07,g:.07,b:.07},w:1.2,px:0,py:0,was:false},
led:null,ledMain:null,sleep:0,steer:{x:0,y:0},cruise:0,speedF:1,brake:0,pend:null,hit:0,hitK:0,hitN:{x:0,y:0},hitNext:0,hitNK:0,hitNN:{x:0,y:0},ov:null,k:1,seed:i*7+1,sel:false}}
function setBh(b,name){b.bh=name;b.bt=0;b.enter=true;b.st={};b.pend=null}
function spawn(n){n=clamp(n|0,1,CAP);W.n=n;W.B.length=0;W.sel.clear();W.watch=-1;W.queue=[];W.msgDbg.length=0;
for(let i=0;i<n;i++){const b=mkBoid(i);b.k=1+(W.rand()*2-1)*W.sim.spread/100;
let x,y,tries=0;do{x=(W.rand()-.5)*0.9;y=(W.rand()-.5)*0.9;tries++}while(tries<30&&(W.obst.some(o=>obstDist(o,x,y)<R*2)||nearAny(i,x,y)));
W.x[i]=x;W.y[i]=y;const a=W.rand()*TAU;W.h[i]=a;const v=0.4*mS(b);W.vx[i]=Math.cos(a)*v;W.vy[i]=Math.sin(a)*v;W.sp[i]=v;
setBh(b,W.plans[W.init.bh]?W.init.bh:Object.keys(W.plans)[0]||'');W.B.push(b)}
clearInk()}
function nearAny(n,x,y){for(let j=0;j<n;j++)if(Math.hypot(W.x[j]-x,W.y[j]-y)<R*2.2)return true;return false}
function obstDist(o,x,y){if(o.t==='c')return Math.hypot(x-o.x,y-o.y)-o.r;const qx=clamp(x,o.x0,o.x1),qy=clamp(y,o.y0,o.y1);return Math.hypot(x-qx,y-qy)}
/* ---- Nachrichten ---- */
W.send=(b,ch,val,mode,arg)=>{const S=W.sim;
const push=j=>{if(j===b.i||j<0||j>=W.n)return;if(S.loss>0&&W.rand()*100<S.loss)return;
W.queue.push({due:W.tick+S.latency,to:j,ch,v:val,from:b.i});if(W.dbgTick)W.dMsg(b.i,j)};
if(mode==='all'){for(let j=0;j<W.n;j++)push(j)}else if(mode==='direct'){if(typeof arg==='number')push(arg)}
else for(const j of W.query(b.i,arg/100,360))push(j)};
function deliver(){const q=W.queue;if(!q.length)return;const keep=[];
for(const m of q){if(m.due<=W.tick){const b=W.B[m.to];if(b)b.inbox[m.ch]={v:m.v,from:m.from,tick:W.tick,t:W.t}}else keep.push(m)}W.queue=keep}
function globalMsg(ch,val){
const B=W.B;
switch(ch){
case'penup':B.forEach(b=>b.pen.down=false);break;
case'pendown':B.forEach(b=>b.pen.down=true);break;
case'sleep':B.forEach(b=>b.sleep=Infinity);break;
case'wake':B.forEach(b=>b.sleep=0);break;
case'scatter':B.forEach(b=>{const i=b.i;W.x[i]=(W.rand()-.5)*0.9;W.y[i]=(W.rand()-.5)*0.9;W.h[i]=W.rand()*TAU;W.vx[i]=W.vy[i]=W.sp[i]=0;b.pen.was=false});break;
case'clearink':clearInk();break;
case'reset':spawn(W.n);return;
default:if(ch.startsWith('bh:')&&W.plans[ch.slice(3)])B.forEach(b=>setBh(b,ch.slice(3)));
}
for(const b of B)b.inbox[ch]={v:val??1,from:-1,tick:W.tick+1,t:W.t}}
W.globalMsg=globalMsg;
/* ================= Graph-Compiler ================= */
const DEFS={};
const IN=(n,t='n')=>({n,t}),OUT=(n,t='n')=>({n,t});
const PN=(n,d,o={})=>({n,t:'n',d,...o}),PS=(n,o,d)=>({n,t:'s',o,d:d??o[0]}),PT=(n,d)=>({n,t:'t',d}),PC=(n,d)=>({n,t:'c',d}),PK=(n,d)=>({n,t:'k',d}),PB=(n,d)=>({n,t:'bh',d});
function defNode(d){d.ins=d.ins||[];d.outs=d.outs||[];d.params=d.params||[];
if(d.act&&!d.ins.some(x=>x.n==='do'))d.ins=[...d.ins,{n:'do',t:'b'}];
d.cat=d.cat||'math';d.label=d.label||d.id;DEFS[d.id]=d;return d}
const defParams=t=>{const o={};for(const p of(DEFS[t]?.params||[]))o[p.n]=p.d;return o};
function compile(g){
const nodes=g.nodes,byId={};nodes.forEach(n=>byId[n.id]=n);
const inc={};g.wires.forEach(w=>{if(byId[w.f]&&byId[w.t])(inc[w.t]??={})[w.ti]=w});
const state={},order=[],err=[];
const visit=id=>{if(state[id]===2)return;if(state[id]===1){err.push('Zyklus bei '+id);return}state[id]=1;
for(const k in inc[id]||{})visit(inc[id][k].f);state[id]=2;order.push(id)};
nodes.forEach(n=>visit(n.id));
const slot={};let cnt=0;
for(const id of order){const d=DEFS[byId[id].type];slot[id]=cnt;cnt+=d?Math.max(1,d.outs.length):1}
const steps=[];
for(const id of order){const n=byId[id],d=DEFS[n.type];if(!d){err.push('unbekannter Node: '+n.type);continue}
const src=d.ins.map(inp=>{const w=inc[id]?.[inp.n];if(!w)return-1;const sd=DEFS[byId[w.f].type];if(!sd)return-1;const oi=sd.outs.findIndex(o=>o.n===w.fo);return oi<0?-1:slot[w.f]+oi});
const p={...defParams(n.type),...n.params};
steps.push({id,d,ins:d.ins.map(x=>x.n),src,base:slot[id],I:{},p,ex:(n.ex||[]).filter(k=>k in p),doIdx:d.act?d.ins.findIndex(x=>x.n==='do'):-1})}
return{steps,V:new Array(cnt),err:err.length?err.join('; '):null}}
function paramsFor(s,b){if(!s.ex.length)return s.p;const o=b.ov&&b.ov[s.id];const k=W.sim.spread>0?b.k:1;if(!o&&k===1)return s.p;
const q={...s.p};for(const nm of s.ex){const has=o&&nm in o;let v=has?o[nm]:s.p[nm];if(typeof v==='number'&&k!==1&&!has)v*=k;q[nm]=v}return q}
function runPlan(plan,b){
const V=plan.V,watch=b.i===W.watch;if(watch)W.snap={};
for(const s of plan.steps){
const d=s.d,I=s.I,p=paramsFor(s,b);
for(let k=0;k<s.ins.length;k++){const nm=s.ins[k],sl=s.src[k];I[nm]=sl>=0?V[sl]:p[nm]}
if(s.doIdx>=0){const dv=I.do;if(dv!==undefined&&!truthy(dv)){if(watch)W.snap[s.id]=[false];continue}}
const r=d.run(I,p,b,W,s),o=d.outs;
if(o.length===1)V[s.base]=r;else for(let k=0;k<o.length;k++)V[s.base+k]=r?r[o[k].n]:undefined;
if(watch)W.snap[s.id]=o.length?o.map((_,k)=>V[s.base+k]):[true]}}
/* ================= Simulation ================= */
function evalBoid(b){
const plan=W.plans[b.bh];
b.hit=b.hitNext;b.hitK=b.hitNK;b.hitN=b.hitNN;b.hitNext=0;
b.steer.x=b.steer.y=0;b.cruise=0;b.speedF=1;b.brake=0;
if(plan&&!plan.bad){try{runPlan(plan,b)}catch(e){plan.bad=true;W.runErr=`${b.bh}: ${e.message}`;console.error(e)}}
b.bt+=DT;b.enter=false}
function ledFor(b){return b.led}
function physics(){
const S=W.sim,n=W.n,diff=S.drive==='diff',X=W.x,Y=W.y,VX=W.vx,VY=W.vy,Hd=W.h,SP=W.sp,rest=S.rest;
for(let i=0;i<n;i++){const b=W.B[i],ms=mS(b),ma=mA(b),mt=S.maxTurn*(S.spread>0?b.k:1);
if(b.sleep>0){const f=Math.exp(-8*DT);VX[i]*=f;VY[i]*=f;SP[i]*=f;b.sleep-=DT;if(b.sleep<0)b.sleep=0}
else if(!diff){
let ax=b.steer.x*ma,ay=b.steer.y*ma;const vx=VX[i],vy=VY[i],sp=Math.hypot(vx,vy),cr=b.cruise*ms,lim=ms*b.speedF;
if(b.brake){const f=Math.exp(-6*DT);VX[i]*=f;VY[i]*=f}
else{if(cr>0&&sp<cr){const k=ma*(1-sp/cr);ax+=Math.cos(Hd[i])*k;ay+=Math.sin(Hd[i])*k}
VX[i]+=ax*DT;VY[i]+=ay*DT;const f=1/(1+S.friction*DT);VX[i]*=f;VY[i]*=f}
const s2=Math.hypot(VX[i],VY[i]);if(s2>lim){VX[i]*=lim/s2;VY[i]*=lim/s2}
if(s2>0.004){const th=Math.atan2(VY[i],VX[i]);Hd[i]+=clamp(wrapA(th-Hd[i]),-mt*DT,mt*DT)}
}else{
const cr=b.cruise*ms,lim=ms*b.speedF;let dx=Math.cos(Hd[i])*cr+b.steer.x*ms,dy=Math.sin(Hd[i])*cr+b.steer.y*ms;
const dl=Math.hypot(dx,dy);let tgt=0;
if(dl>1e-4&&!b.brake){const th=Math.atan2(dy,dx),e=wrapA(th-Hd[i]);Hd[i]+=clamp(e*8,-mt,mt)*DT;const c=Math.max(0,Math.cos(e));tgt=Math.min(dl,lim)*c*c}
SP[i]+=clamp(tgt-SP[i],-ma*DT*2,ma*DT);if(b.brake)SP[i]*=Math.exp(-6*DT);
VX[i]=Math.cos(Hd[i])*SP[i];VY[i]=Math.sin(Hd[i])*SP[i]}
X[i]+=VX[i]*DT;Y[i]+=VY[i]*DT}
gridBuild();
const mark=(b,k,nx,ny)=>{b.hitNext=1;b.hitNK=k;b.hitNN={x:nx,y:ny}};
for(let i=0;i<n;i++){const cx=cIdx(X[i]),cy=cIdx(Y[i]);
for(let gy=Math.max(0,cy-1);gy<=Math.min(GN-1,cy+1);gy++)for(let gx=Math.max(0,cx-1);gx<=Math.min(GN-1,cx+1);gx++)
for(let j=gHead[gy*GN+gx];j>=0;j=gNext[j]){if(j<=i)continue;
let dx=X[j]-X[i],dy=Y[j]-Y[i];const d2=dx*dx+dy*dy,m=2*R;if(d2>=m*m)continue;
let d=Math.sqrt(d2);if(d<1e-7){dx=1;dy=0;d=1e-7}const nx=dx/d,ny=dy/d,ov=(m-d)*0.5;
X[i]-=nx*ov;Y[i]-=ny*ov;X[j]+=nx*ov;Y[j]+=ny*ov;
const vn=(VX[i]-VX[j])*nx+(VY[i]-VY[j])*ny;
if(vn>0){const jj=(1+rest)*vn*0.5;VX[i]-=jj*nx;VY[i]-=jj*ny;VX[j]+=jj*nx;VY[j]+=jj*ny}
mark(W.B[i],2,-nx,-ny);mark(W.B[j],2,nx,ny)}}
const lo=-HALF+R,hi=HALF-R;
for(let i=0;i<n;i++){const b=W.B[i];
if(X[i]<lo){X[i]=lo;if(VX[i]<0)VX[i]*=-rest;mark(b,1,1,0)}else if(X[i]>hi){X[i]=hi;if(VX[i]>0)VX[i]*=-rest;mark(b,1,-1,0)}
if(Y[i]<lo){Y[i]=lo;if(VY[i]<0)VY[i]*=-rest;mark(b,1,0,1)}else if(Y[i]>hi){Y[i]=hi;if(VY[i]>0)VY[i]*=-rest;mark(b,1,0,-1)}
for(const o of W.obst){let nx=0,ny=0,pen=0;
if(o.t==='c'){const dx=X[i]-o.x,dy=Y[i]-o.y,d=Math.hypot(dx,dy),m=o.r+R;if(d<m){const dd=d||1e-6;nx=dx/dd;ny=dy/dd;pen=m-d}}
else{const qx=clamp(X[i],o.x0,o.x1),qy=clamp(Y[i],o.y0,o.y1);let dx=X[i]-qx,dy=Y[i]-qy;const d=Math.hypot(dx,dy);
if(d>1e-7){if(d<R){nx=dx/d;ny=dy/d;pen=R-d}}
else{const l=X[i]-o.x0,r=o.x1-X[i],t=Y[i]-o.y0,bt=o.y1-Y[i],mn=Math.min(l,r,t,bt);
if(mn===l){nx=-1;ny=0}else if(mn===r){nx=1;ny=0}else if(mn===t){nx=0;ny=-1}else{nx=0;ny=1}pen=mn+R}}
if(pen>0){X[i]+=nx*pen;Y[i]+=ny*pen;const vn=VX[i]*nx+VY[i]*ny;if(vn<0){VX[i]-=(1+rest)*vn*nx;VY[i]-=(1+rest)*vn*ny}mark(b,3,nx,ny)}}
if(diff){SP[i]=VX[i]*Math.cos(Hd[i])+VY[i]*Math.sin(Hd[i]);VX[i]=Math.cos(Hd[i])*SP[i];VY[i]=Math.sin(Hd[i])*SP[i]}
penDraw(b,i)}}
function step(){
W.t+=DT;W.tick++;deliver();gridBuild();
const n=W.n;
for(let i=0;i<n;i++){const b=W.B[i];if(b.sleep>0){b.steer.x=b.steer.y=0;b.hit=b.hitNext;b.hitNext=0;continue}evalBoid(b)}
physics();
for(let i=0;i<n;i++){const b=W.B[i];if(b.pend!==null){if(W.plans[b.pend])setBh(b,b.pend);b.pend=null}}
if(W.sim.fade>0){fadeAcc+=DT/W.sim.fade;const s=Math.floor(fadeAcc*255);if(s>=1){fadeAcc-=s/255;fadeStep+=s;for(let k=0;k<ink.length;k++)if(ink[k])ink[k]=Math.max(0,ink[k]-s)}}
else{fadeAcc=0}
}
/* ================= LED-Programm → Pixel ================= */
function ledRender(b,t,out,o){
for(let k=0;k<48;k++)out[o+k]=0;
const L=b.led;if(!L||L.mode==='off')return;
const c=L.c||{r:1,g:1,b:1},dim=b.sleep>0?0.25:1,a=new Float32Array(12);
switch(L.mode){
case'fill':a.fill(1);break;
case'progress':{const nl=Math.round(clamp(L.val||0,0,1)*12);for(let k=0;k<nl;k++)a[(12-k)%12]=1;break}
case'pointer':{const i0=Math.round((L.ang||0)/30);for(let d=-1;d<=1;d++)a[(((i0+d)%12)+12)%12]=d===0?1:0.35;break}
case'segment':{const i0=(L.ang||0)/30,n=Math.max(1,L.n|0);for(let k=0;k<n;k++){const idx=Math.round(i0+k-(n-1)/2);a[((idx%12)+12)%12]=1}break}
case'spin':{const head=Math.floor(t*(L.hz||1)*12)%12,n=Math.max(1,L.n|0);for(let k=0;k<n;k++)a[((head-k)%12+12)%12]=1-k/n;break}
case'pulse':a.fill(0.5+0.5*Math.sin(TAU*(L.hz||1)*t));break;
case'blink':a.fill(((t*(L.hz||1))%1)<0.5?1:0);break;
case'pattern':{const s=(L.pat||'').padEnd(12,'0');for(let k=0;k<12;k++){const v=parseInt(s[k],16);a[k]=isNaN(v)?0:(v>1?v/15:v)}break}
}
for(let k=0;k<12;k++){const q=o+k*4;out[q]=c.r*255;out[q+1]=c.g*255;out[q+2]=c.b*255;out[q+3]=a[k]*255*dim}}
/* ================= Rendering ================= */
const canvas=$('#gl');
const renderer=new THREE.WebGLRenderer({canvas,antialias:true,alpha:false});
renderer.setPixelRatio(Math.min(devicePixelRatio||1,2));renderer.setClearColor(0x17171a,1);
const scene=new THREE.Scene();
const cam=new THREE.OrthographicCamera(-1,1,1,-1,-10,10);cam.position.z=5;
let VW=1,VH=1;
function fitCam(){const asp=VW/VH,m=0.56;if(asp>=1){cam.left=-m*asp;cam.right=m*asp;cam.top=m;cam.bottom=-m}else{cam.left=-m;cam.right=m;cam.top=m/asp;cam.bottom=-m/asp}cam.updateProjectionMatrix()}
function resize(){const r=$('#stageWrap').getBoundingClientRect();VW=Math.max(50,r.width);VH=Math.max(50,r.height);renderer.setSize(VW,VH,false);fitCam()}
new ResizeObserver(resize).observe($('#stageWrap'));
const toWorld=(px,py)=>({x:cam.left+px/VW*(cam.right-cam.left),y:cam.top-py/VH*(cam.top-cam.bottom)});
const toScreen=(x,y)=>({x:(x-cam.left)/(cam.right-cam.left)*VW,y:(cam.top-y)/(cam.top-cam.bottom)*VH});
// Bühne (Raster + Rand)
{const m=new THREE.ShaderMaterial({vertexShader:`varying vec2 vP;void main(){vP=position.xy;gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.);}`,
fragmentShader:`varying vec2 vP;void main(){vec2 q=vP*10.;vec2 f=abs(fract(q-.5)-.5)/fwidth(q);float l=1.-min(min(f.x,f.y),1.);
gl_FragColor=vec4(vec3(.925)-vec3(.07)*l,1.);}`});
const mesh=new THREE.Mesh(new THREE.PlaneGeometry(1,1),m);mesh.position.z=-0.01;scene.add(mesh);
const e=new THREE.LineLoop(new THREE.BufferGeometry().setFromPoints([[-.5,-.5],[.5,-.5],[.5,.5],[-.5,.5]].map(p=>new THREE.Vector3(p[0],p[1],0.001))),new THREE.LineBasicMaterial({color:0x44444a}));scene.add(e)}
// Tinte: Ping-Pong-RTs
const INKRES=1024;
const rtOpt={type:THREE.UnsignedByteType,format:THREE.RGBAFormat,minFilter:THREE.LinearFilter,magFilter:THREE.LinearFilter,depthBuffer:false,samples:4};
let rtA=new THREE.WebGLRenderTarget(INKRES,INKRES,rtOpt),rtB=new THREE.WebGLRenderTarget(INKRES,INKRES,rtOpt);
const inkCam=new THREE.OrthographicCamera(-HALF,HALF,HALF,-HALF,-1,1);
const strokeScene=new THREE.Scene();
const sGeo=new THREE.BufferGeometry(),sPos=new Float32Array(SEGCAP*4*3),sCol=new Float32Array(SEGCAP*4*3);
{const idx=new Uint32Array(SEGCAP*6);for(let i=0;i<SEGCAP;i++){const a=i*4;idx.set([a,a+1,a+2,a,a+2,a+3],i*6)}
sGeo.setIndex(new THREE.BufferAttribute(idx,1));sGeo.setAttribute('position',new THREE.BufferAttribute(sPos,3).setUsage(THREE.DynamicDrawUsage));
sGeo.setAttribute('color',new THREE.BufferAttribute(sCol,3).setUsage(THREE.DynamicDrawUsage))}
const strokeMesh=new THREE.Mesh(sGeo,new THREE.ShaderMaterial({transparent:true,premultipliedAlpha:true,depthTest:false,
vertexShader:`attribute vec3 color;varying vec3 vC;void main(){vC=color;gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.);}`,
fragmentShader:`varying vec3 vC;void main(){gl_FragColor=vec4(vC,1.);}`}));
strokeMesh.frustumCulled=false;strokeScene.add(strokeMesh);
const fadeScene=new THREE.Scene(),fadeMat=new THREE.ShaderMaterial({uniforms:{tPrev:{value:null},uDec:{value:0}},depthTest:false,
vertexShader:`varying vec2 vU;void main(){vU=uv;gl_Position=vec4(position.xy*2.,0.,1.);}`,
fragmentShader:`uniform sampler2D tPrev;uniform float uDec;varying vec2 vU;void main(){vec4 c=texture2D(tPrev,vU);float a=max(c.a-uDec,0.);float k=c.a>0.?a/c.a:0.;gl_FragColor=vec4(c.rgb*k,a);}`});
fadeScene.add(new THREE.Mesh(new THREE.PlaneGeometry(1,1),fadeMat));
const inkMat=new THREE.ShaderMaterial({uniforms:{map:{value:rtA.texture}},transparent:true,premultipliedAlpha:true,depthTest:false,
vertexShader:`varying vec2 vU;void main(){vU=uv;gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.);}`,
fragmentShader:`uniform sampler2D map;varying vec2 vU;void main(){gl_FragColor=texture2D(map,vU);}`});
const inkPlane=new THREE.Mesh(new THREE.PlaneGeometry(1,1),inkMat);inkPlane.position.z=0.002;scene.add(inkPlane);
function flushInk(){
renderer.autoClear=false;
if(inkClearReq){for(const rt of[rtA,rtB]){renderer.setRenderTarget(rt);renderer.setClearColor(0x000000,0);renderer.clear()}renderer.setClearColor(0x17171a,1);inkClearReq=false;fadeStep=0}
if(fadeStep>0){fadeMat.uniforms.tPrev.value=rtA.texture;fadeMat.uniforms.uDec.value=fadeStep/255;renderer.setRenderTarget(rtB);renderer.render(fadeScene,inkCam);[rtA,rtB]=[rtB,rtA];fadeStep=0}
if(segN>0){
for(let s=0;s<segN;s++){const o=s*8,x0=segs[o],y0=segs[o+1],x1=segs[o+2],y1=segs[o+3],hw=Math.max(0.0004,segs[o+7]*0.0005);
let dx=x1-x0,dy=y1-y0;const l=Math.hypot(dx,dy)||1;dx/=l;dy/=l;const nx=-dy*hw,ny=dx*hw,ex=dx*hw,ey=dy*hw,q=s*12;
sPos[q]=x0-ex+nx;sPos[q+1]=y0-ey+ny;sPos[q+3]=x0-ex-nx;sPos[q+4]=y0-ey-ny;sPos[q+6]=x1+ex-nx;sPos[q+7]=y1+ey-ny;sPos[q+9]=x1+ex+nx;sPos[q+10]=y1+ey+ny;
for(let k=0;k<4;k++){sCol[q+k*3]=segs[o+4];sCol[q+k*3+1]=segs[o+5];sCol[q+k*3+2]=segs[o+6]}}
sGeo.attributes.position.needsUpdate=true;sGeo.attributes.color.needsUpdate=true;sGeo.setDrawRange(0,segN*6);
renderer.setRenderTarget(rtA);renderer.render(strokeScene,inkCam);segN=0}
renderer.setRenderTarget(null);renderer.autoClear=true;inkMat.uniforms.map.value=rtA.texture}
// Boids (Instancing + LED-Shader)
const ledData=new Uint8Array(12*CAP*4),ledTex=new THREE.DataTexture(ledData,12,CAP,THREE.RGBAFormat);
ledTex.minFilter=ledTex.magFilter=THREE.NearestFilter;ledTex.needsUpdate=true;
const bGeo=new THREE.InstancedBufferGeometry();{const pg=new THREE.PlaneGeometry(1,1);bGeo.index=pg.index;bGeo.setAttribute('position',pg.attributes.position)}
const iA=new Float32Array(CAP*4),iB=new Float32Array(CAP*4),iC=new Float32Array(CAP*3);
bGeo.setAttribute('iA',new THREE.InstancedBufferAttribute(iA,4).setUsage(THREE.DynamicDrawUsage));
bGeo.setAttribute('iB',new THREE.InstancedBufferAttribute(iB,4).setUsage(THREE.DynamicDrawUsage));
bGeo.setAttribute('iC',new THREE.InstancedBufferAttribute(iC,3).setUsage(THREE.DynamicDrawUsage));
const bMat=new THREE.ShaderMaterial({transparent:true,depthTest:false,uniforms:{uS:{value:2*R*1.13},uLed:{value:ledTex},uCap:{value:CAP}},
vertexShader:`attribute vec4 iA;attribute vec4 iB;attribute vec3 iC;uniform float uS;varying vec2 vP;varying float vIdx;varying vec4 vB;varying vec3 vC;
void main(){vP=position.xy*2.;vIdx=iA.w;vB=iB;vC=iC;float c=cos(iA.z),s=sin(iA.z);vec2 q=position.xy*uS;vec2 w=vec2(c*q.x-s*q.y,s*q.x+c*q.y)+iA.xy;gl_Position=projectionMatrix*modelViewMatrix*vec4(w,0.01,1.);}`,
fragmentShader:`uniform sampler2D uLed;uniform float uCap;varying vec2 vP;varying float vIdx;varying vec4 vB;varying vec3 vC;
void main(){vec2 p=vP;float d=length(p);float aa=fwidth(d)*1.2;const float rb=.885;
float body=1.-smoothstep(rb-aa,rb+aa,d);
float sel=vB.x*smoothstep(.93-aa,.93+aa,d)*(1.-smoothstep(1.-aa,1.+aa,d));
if(body<.002&&sel<.002)discard;
vec3 col=vec3(.05);
col=mix(col,vec3(.14),smoothstep(rb-.075-aa,rb-.075+aa,d));
const float TW=6.2831853;float seg=TW/12.;float u=mod(atan(p.y,p.x)+seg*.5,TW)/seg;float idx=floor(u);float fr=fract(u)-.5;
float ring=smoothstep(.57-aa,.57+aa,d)*(1.-smoothstep(.76-aa,.76+aa,d))*(1.-smoothstep(.36,.42,abs(fr)));
vec4 L=texture2D(uLed,vec2((idx+.5)/12.,(vIdx+.5)/uCap));
col=mix(col,mix(vec3(.2),L.rgb,L.a),ring);
float ax=(p.x+.26)/.74;float ar=0.;if(ax>0.&&ax<1.){float hw=.3*(1.-ax);ar=1.-smoothstep(hw-aa*2.,hw+aa*2.,abs(p.y));}
ar*=1.-smoothstep(.47-aa,.47+aa,d);col=mix(col,vec3(.98),ar);
if(vB.z>.5){float pr=length(p-vec2(-.06,0.));col=mix(col,vec3(1.),1.-smoothstep(.16-aa,.16+aa,pr));col=mix(col,vC,1.-smoothstep(.115-aa,.115+aa,pr));}
col=mix(col,vec3(.42),vB.y*.5);
gl_FragColor=vec4(mix(col,vec3(1.,.23,.42),sel),max(body,sel));}`});
const bMesh=new THREE.Mesh(bGeo,bMat);bMesh.frustumCulled=false;bMesh.renderOrder=5;scene.add(bMesh);
// Hindernisse
const obstGroup=new THREE.Group();scene.add(obstGroup);
function rebuildObst(){while(obstGroup.children.length){const c=obstGroup.children.pop();c.geometry?.dispose()}
const fill=new THREE.MeshBasicMaterial({color:0xdcdcdc,depthTest:false}),line=new THREE.LineBasicMaterial({color:0x111111,depthTest:false});
for(const o of W.obst){let f,l;
if(o.t==='c'){f=new THREE.Mesh(new THREE.CircleGeometry(o.r,48),fill);f.position.set(o.x,o.y,0.004);
const pts=[];for(let k=0;k<48;k++)pts.push(new THREE.Vector3(o.x+Math.cos(k/48*TAU)*o.r,o.y+Math.sin(k/48*TAU)*o.r,0.005));l=new THREE.LineLoop(new THREE.BufferGeometry().setFromPoints(pts),line)}
else{f=new THREE.Mesh(new THREE.PlaneGeometry(o.x1-o.x0,o.y1-o.y0),fill);f.position.set((o.x0+o.x1)/2,(o.y0+o.y1)/2,0.004);
l=new THREE.LineLoop(new THREE.BufferGeometry().setFromPoints([[o.x0,o.y0],[o.x1,o.y0],[o.x1,o.y1],[o.x0,o.y1]].map(p=>new THREE.Vector3(p[0],p[1],0.005))),line)}
f.renderOrder=l.renderOrder=3;obstGroup.add(f,l)}}
// Debug-Linien
const dGeo=new THREE.BufferGeometry();dGeo.setAttribute('position',new THREE.BufferAttribute(D.pos,3).setUsage(THREE.DynamicDrawUsage));dGeo.setAttribute('color',new THREE.BufferAttribute(D.col,3).setUsage(THREE.DynamicDrawUsage));
const dMesh=new THREE.LineSegments(dGeo,new THREE.LineBasicMaterial({vertexColors:true,depthTest:false,transparent:true,opacity:.9}));dMesh.frustumCulled=false;dMesh.renderOrder=9;scene.add(dMesh);
const lblEl=$('#lbl'),lbls=[];let lblT=0;
function debugFrame(){
D.n=D.nodeN;
if(D.scope!=='off'){
const act=b=>D.scope==='all'||b.sel,s=0.35;
for(const b of W.B){if(!act(b))continue;const i=b.i,x=W.x[i],y=W.y[i];
if(D.on.vel&&b.sleep<=0)dseg(x,y,x+W.vx[i]*s,y+W.vy[i]*s,'vel');
if(D.on.steer&&b.sleep<=0)dseg(x,y,x+b.steer.x*0.04,y+b.steer.y*0.04,'steer')}
if(D.on.msg){const now=W.t;W.msgDbg=W.msgDbg.filter(m=>now-m.t<0.4);for(const m of W.msgDbg)if(m.a<W.n&&m.c<W.n)dseg(W.x[m.a],W.y[m.a],W.x[m.c],W.y[m.c],'msg')}
else W.msgDbg.length=0}
dGeo.setDrawRange(0,D.n);dGeo.attributes.position.needsUpdate=true;dGeo.attributes.color.needsUpdate=true}
function labelsFrame(now){
if(now-lblT<100)return;lblT=now;let k=0;
if(D.on.id&&D.scope!=='off'){for(const b of W.B){if(D.scope==='sel'&&!b.sel)continue;if(k>=140)break;
let el=lbls[k];if(!el){el=document.createElement('span');lblEl.appendChild(el);lbls[k]=el}
const p=toScreen(W.x[b.i],W.y[b.i]);el.style.left=p.x+'px';el.style.top=p.y+'px';el.textContent=D.scope==='sel'?`#${b.id} ${b.bh}${b.sleep>0?' ·zz':''}`:b.id;el.style.display='';k++}}
for(;k<lbls.length;k++)lbls[k].style.display='none'}
const _t0=performance.now();
function render(now){
const n=W.n,t=W.t;
for(let i=0;i<n;i++){const b=W.B[i],a=i*4;iA[a]=W.x[i];iA[a+1]=W.y[i];iA[a+2]=W.h[i];iA[a+3]=i;
iB[a]=b.sel?1:0;iB[a+1]=b.sleep>0?1:0;iB[a+2]=b.pen.down?1:0;iB[a+3]=0;iC[i*3]=b.pen.c.r;iC[i*3+1]=b.pen.c.g;iC[i*3+2]=b.pen.c.b;
ledRender(b,(now-_t0)/1000,ledData,i*48)}
bGeo.attributes.iA.needsUpdate=true;bGeo.attributes.iB.needsUpdate=true;bGeo.attributes.iC.needsUpdate=true;bGeo.instanceCount=n;ledTex.needsUpdate=true;
flushInk();debugFrame();labelsFrame(now);
renderer.render(scene,cam)}
/* ==========================================================================
NODE-BIBLIOTHEK — hier kommen neue Nodes hin (oder per LLM als "user node")
Konvention: Einheiten in Nodes = cm, Grad, Sekunden. Vektoren {x,y} in Metern,
Farben {r,g,b} 0..1, Listen = Arrays von Boid-Indizes, Boid-Referenz = Index.
Ein Input, der unverbunden ist, nimmt den Wert des gleichnamigen Parameters.
Actions haben automatisch einen "do"-Eingang: unverbunden = jeden Tick, sonst nur bei Impuls.
========================================================================== */
/* ---------- SENSE ---------- */
defNode({id:'self',cat:'sense',label:'Self',doc:'Eigener Zustand',outs:[OUT('pos','v'),OUT('vel','v'),OUT('dir','v'),OUT('speed'),OUT('angle'),OUT('id'),OUT('time')],
run:(i,p,b,w)=>{const k=b.i,h=w.h[k];return{pos:V2(w.x[k],w.y[k]),vel:V2(w.vx[k],w.vy[k]),dir:V2(Math.cos(h),Math.sin(h)),speed:Math.hypot(w.vx[k],w.vy[k])*100,angle:h*180/Math.PI,id:b.id,time:b.bt}}});
defNode({id:'neighbors',cat:'sense',label:'Neighbors',doc:'Alle Boids im Radius (optional Sichtfeld)',ins:[IN('radius')],outs:[OUT('list','l')],
params:[PN('radius',14,{min:1,max:100,u:'cm'}),PN('fov',360,{min:10,max:360,u:'°'})],
run:(i,p,b,w)=>{const r=(i.radius??p.radius)/100,l=w.query(b.i,r,p.fov),x=w.x[b.i],y=w.y[b.i];
if(w.dbgOn(b,'rad'))w.dc(x,y,r,'rad');if(w.dbgOn(b,'nbr'))for(const j of l)w.dl(x,y,w.x[j],w.y[j],'nbr');return l}});
defNode({id:'pick',cat:'sense',label:'Pick',doc:'Einen Boid aus einer Liste wählen (nächster, entferntester, zufällig…)',ins:[IN('list','l')],outs:[OUT('boid','o'),OUT('found','b'),OUT('pos','v'),OUT('dist')],
params:[PS('mode',['nearest','farthest','random','lowest id','highest id','first']),PN('radius',20,{u:'cm'})],
run:(i,p,b,w)=>{const l=i.list||w.query(b.i,p.radius/100,360),x=w.x[b.i],y=w.y[b.i];
if(!l.length)return{boid:-1,found:false,pos:V2(x,y),dist:0};
let best=l[0],bv=null;const m=p.mode;
if(m==='random')best=l[Math.floor(w.rand()*l.length)];
else if(m!=='first')for(const j of l){const d=Math.hypot(w.x[j]-x,w.y[j]-y);const v=m==='nearest'?d:m==='farthest'?-d:m==='lowest id'?w.B[j].id:-w.B[j].id;if(bv===null||v<bv){bv=v;best=j}}
const d=Math.hypot(w.x[best]-x,w.y[best]-y);if(w.dbgOn(b,'tgt')){w.dx(w.x[best],w.y[best],'tgt');w.dl(x,y,w.x[best],w.y[best],'tgt')}
return{boid:best,found:true,pos:V2(w.x[best],w.y[best]),dist:d*100}}});
const FIELDS=['dist','id','speed','bearing','hue','behaviour','var'];
function fieldVal(w,b,j,f,txt){const B=w.B[j];switch(f){case'dist':return Math.hypot(w.x[j]-w.x[b.i],w.y[j]-w.y[b.i])*100;case'id':return B.id;case'speed':return Math.hypot(w.vx[j],w.vy[j])*100;
case'bearing':return Math.abs(wrapA(Math.atan2(w.y[j]-w.y[b.i],w.x[j]-w.x[b.i])-w.h[b.i]))*180/Math.PI;case'hue':return B.ledMain?hueOf(B.ledMain):-1;case'behaviour':return B.bh;case'var':return B.vars[txt]}}
const cmp=(op,a,b)=>op==='<'?a<b:op==='>'?a>b:op==='≤'?a<=b:op==='≥'?a>=b:op==='='?a==b:a!=b;
defNode({id:'filter',cat:'sense',label:'Filter',doc:'Liste filtern: Feld (dist,id,speed,bearing,hue,behaviour,var) Operator Wert. Bei behaviour/var steht der Name in txt.',ins:[IN('list','l'),IN('val')],outs:[OUT('list','l')],
params:[PS('field',FIELDS),PS('op',['<','>','≤','≥','=','≠']),PN('val',0),PT('txt','')],
run:(i,p,b,w)=>{const l=i.list||[],f=p.field;return l.filter(j=>{const v=fieldVal(w,b,j,f,p.txt);if(f==='behaviour')return p.op==='≠'?v!==p.txt:v===p.txt;if(v===undefined)return false;return cmp(p.op,+v,+i.val)})}});
defNode({id:'count',cat:'sense',label:'Count',ins:[IN('list','l')],outs:[OUT('n')],run:i=>i.list?i.list.length:0});
defNode({id:'average',cat:'sense',label:'Average',doc:'Mittelwert aus Liste: Position, Geschwindigkeit oder Richtung',ins:[IN('list','l')],outs:[OUT('v','v'),OUT('n')],params:[PS('what',['pos','vel','dir'])],
run:(i,p,b,w)=>{const l=i.list||[];if(!l.length)return{v:p.what==='pos'?V2(w.x[b.i],w.y[b.i]):V2(),n:0};let sx=0,sy=0;
for(const j of l){if(p.what==='pos'){sx+=w.x[j];sy+=w.y[j]}else if(p.what==='vel'){sx+=w.vx[j];sy+=w.vy[j]}else{sx+=Math.cos(w.h[j]);sy+=Math.sin(w.h[j])}}
return{v:V2(sx/l.length,sy/l.length),n:l.length}}});
defNode({id:'boid',cat:'sense',label:'Boid Info',doc:'Daten eines Boids über seine Referenz',ins:[IN('boid','o')],outs:[OUT('pos','v'),OUT('vel','v'),OUT('dir','v'),OUT('dist'),OUT('hue'),OUT('id'),OUT('found','b')],
run:(i,p,b,w)=>{const j=i.boid;if(typeof j!=='number'||j<0||j>=w.n)return{pos:V2(),vel:V2(),dir:V2(),dist:0,hue:-1,id:-1,found:false};
return{pos:V2(w.x[j],w.y[j]),vel:V2(w.vx[j],w.vy[j]),dir:V2(Math.cos(w.h[j]),Math.sin(w.h[j])),dist:Math.hypot(w.x[j]-w.x[b.i],w.y[j]-w.y[b.i])*100,hue:w.B[j].ledMain?hueOf(w.B[j].ledMain):-1,id:w.B[j].id,found:true}}});
defNode({id:'wall',cat:'sense',label:'Wall',doc:'Nächste Wand: Abstand, Normale (nach innen)',outs:[OUT('dist'),OUT('normal','v'),OUT('near','b')],params:[PN('margin',6,{u:'cm'})],
run:(i,p,b,w)=>{const x=w.x[b.i],y=w.y[b.i],ds=[x+HALF,HALF-x,y+HALF,HALF-y],ns=[[1,0],[-1,0],[0,1],[0,-1]];let k=0;for(let q=1;q<4;q++)if(ds[q]<ds[k])k=q;
return{dist:ds[k]*100,normal:V2(ns[k][0],ns[k][1]),near:ds[k]*100<p.margin}}});
defNode({id:'ray',cat:'sense',label:'Ray',doc:'Strahl gegen Wände/Hindernisse; Winkel relativ zur Blickrichtung',outs:[OUT('hit','b'),OUT('dist'),OUT('point','v'),OUT('normal','v')],params:[PN('angle',0,{min:-180,max:180,u:'°'}),PN('length',20,{u:'cm'})],
run:(i,p,b,w)=>{const x=w.x[b.i],y=w.y[b.i],a=w.h[b.i]+p.angle*Math.PI/180,dx=Math.cos(a),dy=Math.sin(a),L=p.length/100,r=w.rayCast(x,y,dx,dy,L);
if(w.dbgOn(b,'ray'))w.dl(x,y,x+dx*r.t,y+dy*r.t,'ray');
return{hit:r.hit,dist:r.t*100,point:V2(x+dx*r.t,y+dy*r.t),normal:V2(r.nx,r.ny)}}});
defNode({id:'ink',cat:'sense',label:'Ink',doc:'Tinte um den Boid: value vorn, grad = Richtung zu mehr Tinte, here = unter mir',outs:[OUT('value'),OUT('grad','v'),OUT('here')],params:[PN('ahead',3,{min:1,max:20,u:'cm'})],
run:(i,p,b,w)=>{const x=w.x[b.i],y=w.y[b.i],h=w.h[b.i],d=p.ahead/100;let gx=0,gy=0,tot=0;
for(let k=0;k<8;k++){const a=k*Math.PI/4,dx=Math.cos(a),dy=Math.sin(a),v=w.inkAt(x+dx*d,y+dy*d)/255;gx+=dx*v;gy+=dy*v;tot+=v}
return{value:w.inkAt(x+Math.cos(h)*d,y+Math.sin(h)*d)/255,grad:tot>0?V2(gx/tot,gy/tot):V2(),here:w.inkAt(x,y)/255}}});
defNode({id:'point',cat:'sense',label:'Point',doc:'Zielpunkt: fix, Maus, Mitte oder zufällig (neu bei Impuls)',ins:[IN('new','b')],outs:[OUT('pos','v'),OUT('valid','b')],params:[PS('mode',['fixed','mouse','center','random']),PN('x',0,{u:'cm'}),PN('y',0,{u:'cm'})],
run:(i,p,b,w,s)=>{const st=w.st(b,s);let r;
if(p.mode==='fixed')r={pos:V2(p.x/100,p.y/100),valid:true};else if(p.mode==='center')r={pos:V2(),valid:true};else if(p.mode==='mouse')r={pos:V2(w.mouse.x,w.mouse.y),valid:w.mouse.in};
else{if(!st.p||truthy(i.new))st.p=V2((w.rand()-.5)*0.9,(w.rand()-.5)*0.9);r={pos:st.p,valid:true}}
if(r.valid&&w.dbgOn(b,'tgt'))w.dx(r.pos.x,r.pos.y,'tgt');return r}});
defNode({id:'global',cat:'sense',label:'Global G',doc:'Globale Regler G1–G4 (0..1) aus der Kopfleiste',outs:[OUT('n')],params:[PN('idx',1,{min:1,max:4,step:1})],run:(i,p,b,w)=>w.G[clamp((p.idx|0)-1,0,3)]});
defNode({id:'var',cat:'sense',label:'Var',doc:'Variable des Boids lesen',outs:[OUT('value','*'),OUT('has','b')],params:[PT('name','x')],run:(i,p,b)=>({value:b.vars[p.name]??0,has:p.name in b.vars})});
defNode({id:'inbox',cat:'sense',label:'Inbox',doc:'Letzte Nachricht auf einem Kanal (kontinuierlich lesen)',outs:[OUT('value','*'),OUT('from','o'),OUT('has','b'),OUT('age')],params:[PT('channel','msg'),PN('hold',1,{u:'s'})],
run:(i,p,b,w)=>{const m=b.inbox[p.channel];if(!m)return{value:undefined,from:-1,has:false,age:999};const age=w.t-m.t;return{value:m.v,from:m.from,has:age<=p.hold,age}}});
defNode({id:'touch',cat:'sense',label:'Touch',doc:'Berührung im letzten Tick: kind 1=Wand 2=Boid 3=Hindernis',outs:[OUT('hit','b'),OUT('kind'),OUT('normal','v')],run:(i,p,b)=>({hit:!!b.hit,kind:b.hitK,normal:b.hitN})});
/* ---------- EVENTS (Impulse) ---------- */
defNode({id:'onenter',cat:'event',label:'On Enter',doc:'Impuls im ersten Tick nach einem Wechsel in dieses Verhalten',outs:[OUT('out','b')],run:(i,p,b)=>b.enter});
defNode({id:'every',cat:'event',label:'Every',doc:'Impuls alle X Sekunden',outs:[OUT('out','b')],params:[PN('period',5,{min:.1,u:'s'}),PK('desync',true)],
run:(i,p,b,w,s)=>{const st=w.st(b,s);if(st.a===undefined)st.a=p.desync?w.rand()*p.period:0;st.a+=DT;if(st.a>=p.period){st.a-=p.period;return true}return false}});
defNode({id:'onmsg',cat:'event',label:'On Message',doc:'Impuls bei neuer Nachricht auf Kanal',outs:[OUT('out','b'),OUT('value','*'),OUT('from','o')],params:[PT('channel','msg')],
run:(i,p,b,w)=>{const m=b.inbox[p.channel];return m&&m.tick===w.tick?{out:true,value:m.v,from:m.from}:{out:false,value:undefined,from:-1}}});
defNode({id:'chance',cat:'event',label:'Chance',doc:'Zufälliger Impuls, rate = erwartete Impulse pro Sekunde',outs:[OUT('out','b')],params:[PN('rate',0.2,{min:0,step:.05,u:'/s'})],run:(i,p,b,w)=>w.rand()<p.rate*DT});
defNode({id:'rise',cat:'event',label:'Rise',doc:'Impuls bei Wechsel false→true',ins:[IN('in','*')],outs:[OUT('out','b')],run:(i,p,b,w,s)=>{const st=w.st(b,s),v=truthy(i.in),r=v&&!st.prev;st.prev=v;return r}});
defNode({id:'hold',cat:'event',label:'Hold',doc:'Impuls → bleibt X Sekunden true',ins:[IN('in','*')],outs:[OUT('out','b')],params:[PN('secs',2,{u:'s'})],
run:(i,p,b,w,s)=>{const st=w.st(b,s);if(truthy(i.in))st.t=p.secs;const r=(st.t||0)>0;st.t=(st.t||0)-DT;return r}});
defNode({id:'delay',cat:'event',label:'Delay',doc:'Impuls kommt X Sekunden später',ins:[IN('in','*')],outs:[OUT('out','b')],params:[PN('secs',1,{u:'s'})],
run:(i,p,b,w,s)=>{const st=w.st(b,s);st.q=st.q||[];if(truthy(i.in))st.q.push(w.t+p.secs);let r=false;while(st.q.length&&st.q[0]<=w.t){st.q.shift();r=true}return r}});
defNode({id:'latch',cat:'event',label:'Latch',doc:'Merker: set/reset',ins:[IN('set','b'),IN('reset','b')],outs:[OUT('out','b')],run:(i,p,b,w,s)=>{const st=w.st(b,s);if(truthy(i.set))st.v=true;if(truthy(i.reset))st.v=false;return!!st.v}});
/* ---------- MATH / LOGIK ---------- */
defNode({id:'num',cat:'math',label:'Number',outs:[OUT('out')],params:[PN('value',1,{step:.1})],run:(i,p)=>p.value});
defNode({id:'color',cat:'math',label:'Color',doc:'HSV-Farbe (h 0–360, s,v 0–1)',ins:[IN('h'),IN('s'),IN('v')],outs:[OUT('out','c')],params:[PN('h',0,{min:0,max:360,step:5}),PN('s',1,{min:0,max:1,step:.05}),PN('v',1,{min:0,max:1,step:.05})],run:(i)=>hsv(+i.h||0,+i.s,+i.v)});
defNode({id:'vec',cat:'math',label:'Make Vec',doc:'xy: (a,b) · polar: Winkel a (°), Länge b',ins:[IN('a'),IN('b')],outs:[OUT('out','v')],params:[PS('mode',['xy','polar']),PN('a',0,{step:.1}),PN('b',1,{step:.1})],
run:(i,p)=>p.mode==='xy'?V2(+i.a||0,+i.b||0):V2(Math.cos((+i.a||0)*Math.PI/180)*(+i.b||0),Math.sin((+i.a||0)*Math.PI/180)*(+i.b||0))});
defNode({id:'split',cat:'math',label:'Split Vec',ins:[IN('v','v')],outs:[OUT('x'),OUT('y'),OUT('len'),OUT('angle')],run:i=>{const v=isV(i.v)?i.v:V2();return{x:v.x,y:v.y,len:Math.hypot(v.x,v.y),angle:Math.atan2(v.y,v.x)*180/Math.PI}}});
function mop(op,a,b){a=a??0;b=b??0;const va=isV(a),vb=isV(b);
const f=(p,q)=>op==='add'?p+q:op==='sub'?p-q:op==='mul'?p*q:op==='div'?(q?p/q:0):op==='min'?Math.min(p,q):op==='max'?Math.max(p,q):op==='pow'?Math.pow(p,q):op==='mod'?(q?p%q:0):Math.atan2(p,q);
if(va||vb){const A=va?a:{x:+a,y:+a},B=vb?b:{x:+b,y:+b};return{x:f(A.x,B.x),y:f(A.y,B.y)}}return f(+a,+b)}
defNode({id:'math',cat:'math',label:'Math',doc:'Zahlen oder Vektoren: add sub mul div min max pow mod atan2',ins:[IN('a','*'),IN('b','*')],outs:[OUT('out','*')],params:[PS('op',['add','sub','mul','div','min','max','pow','mod','atan2']),PN('a',0,{step:.1}),PN('b',0,{step:.1})],run:(i,p)=>mop(p.op,i.a,i.b)});
defNode({id:'vecop',cat:'math',label:'Vec Op',doc:'normalize limit setlen rotate(°) dot perp neg len angle',ins:[IN('v','v'),IN('a','*')],outs:[OUT('out','*')],params:[PS('op',['normalize','limit','setlen','rotate','dot','perp','neg','len','angle']),PN('a',1,{step:.1})],
run:(i,p)=>{const v=isV(i.v)?i.v:V2(),a=i.a;switch(p.op){case'normalize':return vnorm(v);case'limit':return vlim(v,+a);case'setlen':return vmul(vnorm(v),+a);
case'rotate':{const t=(+a)*Math.PI/180,c=Math.cos(t),s=Math.sin(t);return V2(v.x*c-v.y*s,v.x*s+v.y*c)}case'dot':{const u=isV(a)?a:V2();return v.x*u.x+v.y*u.y}
case'perp':return V2(-v.y,v.x);case'neg':return V2(-v.x,-v.y);case'len':return Math.hypot(v.x,v.y);default:return Math.atan2(v.y,v.x)*180/Math.PI}}});
defNode({id:'map',cat:'math',label:'Map',doc:'Wert von [inMin,inMax] nach [outMin,outMax]',ins:[IN('in')],outs:[OUT('out')],params:[PN('inMin',0),PN('inMax',1),PN('outMin',0),PN('outMax',1),PK('clamp',true)],
run:(i,p)=>{let t=((+i.in||0)-p.inMin)/((p.inMax-p.inMin)||1);if(p.clamp)t=clamp(t,0,1);return p.outMin+t*(p.outMax-p.outMin)}});
defNode({id:'noise',cat:'math',label:'Noise',doc:'Glattes Rauschen −1..1',outs:[OUT('out')],params:[PN('freq',0.3,{step:.05,u:'Hz'}),PK('desync',true)],run:(i,p,b,w)=>noise1(w.t*p.freq+(p.desync?b.seed*0.37:0),17)});
defNode({id:'wave',cat:'math',label:'Wave',doc:'Sinus −1..1',outs:[OUT('out')],params:[PN('freq',0.5,{step:.05,u:'Hz'}),PN('phase',0,{step:.05}),PK('desync',false)],run:(i,p,b,w)=>Math.sin(TAU*(w.t*p.freq+p.phase+(p.desync?b.seed*0.137:0)))});
defNode({id:'rand',cat:'math',label:'Random',doc:'Zufallszahl, bei Impuls "new" neu gewürfelt',ins:[IN('new','b')],outs:[OUT('out')],params:[PN('min',0),PN('max',1)],
run:(i,p,b,w,s)=>{const st=w.st(b,s);if(st.v===undefined||truthy(i.new))st.v=w.rand();return p.min+st.v*(p.max-p.min)}});
defNode({id:'compare',cat:'math',label:'Compare',ins:[IN('a'),IN('b')],outs:[OUT('out','b')],params:[PS('op',['<','>','≤','≥','=','≠']),PN('a',0,{step:.1}),PN('b',0,{step:.1})],run:(i,p)=>cmp(p.op,+i.a||0,+i.b||0)});
defNode({id:'logic',cat:'math',label:'Logic',ins:[IN('a','*'),IN('b','*')],outs:[OUT('out','b')],params:[PS('op',['and','or','xor','not'])],
run:(i,p)=>{const a=truthy(i.a),b=truthy(i.b);return p.op==='and'?a&&b:p.op==='or'?a||b:p.op==='xor'?a!==b:!a}});
defNode({id:'select',cat:'math',label:'Select',doc:'c ? a : b',ins:[IN('c','*'),IN('a','*'),IN('b','*')],outs:[OUT('out','*')],params:[PN('a',1,{step:.1}),PN('b',0,{step:.1})],run:i=>truthy(i.c)?i.a:i.b});
/* ---------- STEER (liefern Kraftvektor, |v| ≤ weight) ---------- */
const steerTo=(b,w,ux,uy,ds)=>{const ms=mS(b);return V2((ux*ds-w.vx[b.i])/ms,(uy*ds-w.vy[b.i])/ms)};
defNode({id:'seek',cat:'steer',label:'Seek',doc:'Zum Ziel (seek), weg davon (flee) oder sanft abbremsend (arrive)',ins:[IN('target','v'),IN('weight')],outs:[OUT('steer','v')],params:[PS('mode',['seek','flee','arrive']),PN('radius',8,{u:'cm'}),PN('weight',1,{min:0,max:3,step:.05})],
run:(i,p,b,w)=>{const t=i.target;if(!isV(t))return V2();const x=w.x[b.i],y=w.y[b.i],dx=t.x-x,dy=t.y-y,d=Math.hypot(dx,dy);if(d<1e-6)return V2();
let ux=dx/d,uy=dy/d,ds=mS(b);if(p.mode==='arrive')ds*=Math.min(1,d/(p.radius/100));if(p.mode==='flee'){ux=-ux;uy=-uy}
return vmul(vlim(steerTo(b,w,ux,uy,ds),1),i.weight)}});
defNode({id:'align',cat:'steer',label:'Align',doc:'Geschwindigkeit der Nachbarn angleichen',ins:[IN('list','l'),IN('weight')],outs:[OUT('steer','v')],params:[PN('weight',1,{min:0,max:3,step:.05})],
run:(i,p,b,w)=>{const l=i.list;if(!l||!l.length)return V2();let sx=0,sy=0;for(const j of l){sx+=w.vx[j];sy+=w.vy[j]}sx/=l.length;sy/=l.length;const ms=mS(b);
return vmul(vlim(V2((sx-w.vx[b.i])/ms,(sy-w.vy[b.i])/ms),1),i.weight)}});
defNode({id:'separate',cat:'steer',label:'Separate',doc:'Abstand halten (nur Nachbarn näher als radius)',ins:[IN('list','l'),IN('weight')],outs:[OUT('steer','v')],params:[PN('radius',5,{min:1,max:50,u:'cm'}),PN('weight',1,{min:0,max:3,step:.05})],
run:(i,p,b,w)=>{const l=i.list;if(!l||!l.length)return V2();const r=p.radius/100,x=w.x[b.i],y=w.y[b.i];let fx=0,fy=0;
for(const j of l){const dx=x-w.x[j],dy=y-w.y[j],d=Math.hypot(dx,dy);if(d<r&&d>1e-6){const k=(1-d/r)/d;fx+=dx*k;fy+=dy*k}}
return vmul(vlim(V2(fx,fy),1),i.weight)}});
defNode({id:'cohere',cat:'steer',label:'Cohere',doc:'Zur Mitte der Nachbarn',ins:[IN('list','l'),IN('weight')],outs:[OUT('steer','v')],params:[PN('weight',1,{min:0,max:3,step:.05})],
run:(i,p,b,w)=>{const l=i.list;if(!l||!l.length)return V2();let cx=0,cy=0;for(const j of l){cx+=w.x[j];cy+=w.y[j]}cx/=l.length;cy/=l.length;
const dx=cx-w.x[b.i],dy=cy-w.y[b.i],d=Math.hypot(dx,dy);if(d<1e-6)return V2();
if(w.dbgOn(b,'tgt'))w.dx(cx,cy,'tgt');return vmul(vlim(steerTo(b,w,dx/d,dy/d,mS(b)*Math.min(1,d/0.08)),1),i.weight)}});
defNode({id:'wander',cat:'steer',label:'Wander',doc:'Zufälliges, glattes Umherstreifen',ins:[IN('weight')],outs:[OUT('steer','v')],params:[PN('rate',2,{min:0,max:10,step:.1}),PN('weight',1,{min:0,max:3,step:.05})],
run:(i,p,b,w,s)=>{const st=w.st(b,s);st.a=clamp((st.a||0)+(w.rand()-.5)*p.rate*DT*10,-1.2,1.2);const a=w.h[b.i]+st.a;return V2(Math.cos(a)*i.weight,Math.sin(a)*i.weight)}});
defNode({id:'avoidwalls',cat:'steer',label:'Avoid Walls',ins:[IN('weight')],outs:[OUT('steer','v')],params:[PN('margin',7,{min:1,max:30,u:'cm'}),PN('weight',1.5,{min:0,max:3,step:.05})],
run:(i,p,b,w)=>{const m=p.margin/100,x=w.x[b.i],y=w.y[b.i];let fx=0,fy=0;
const dl=x+HALF,dr=HALF-x,db=y+HALF,dt=HALF-y;if(dl<m)fx+=1-dl/m;if(dr<m)fx-=1-dr/m;if(db<m)fy+=1-db/m;if(dt<m)fy-=1-dt/m;
return vmul(vlim(V2(fx,fy),1),i.weight)}});
defNode({id:'avoidobst',cat:'steer',label:'Avoid Obstacles',ins:[IN('weight')],outs:[OUT('steer','v')],params:[PN('look',6,{min:1,max:30,u:'cm'}),PN('weight',1.5,{min:0,max:3,step:.05})],
run:(i,p,b,w)=>{const L=p.look/100,x=w.x[b.i],y=w.y[b.i];let fx=0,fy=0;
for(const o of w.obst){let qx,qy;if(o.t==='c'){const dx=x-o.x,dy=y-o.y,d=Math.hypot(dx,dy)||1e-6;qx=o.x+dx/d*o.r;qy=o.y+dy/d*o.r}else{qx=clamp(x,o.x0,o.x1);qy=clamp(y,o.y0,o.y1)}
const dx=x-qx,dy=y-qy,d=Math.hypot(dx,dy);if(d<L+R&&d>1e-6){const k=(1-d/(L+R))/d;fx+=dx*k;fy+=dy*k}}
return vmul(vlim(V2(fx,fy),1),i.weight)}});
/* ---------- ACT ---------- */
defNode({id:'move',cat:'act',label:'Move',act:true,doc:'Summe der Kräfte → Bewegung. cruise = Grundtempo in % der Maximalgeschwindigkeit',ins:[IN('f1','v'),IN('f2','v'),IN('f3','v'),IN('f4','v')],
params:[PN('cruise',60,{min:0,max:100,u:'%'}),PN('speed',100,{min:0,max:100,u:'%'})],
run:(i,p,b)=>{for(const k of['f1','f2','f3','f4']){const f=i[k];if(isV(f)){b.steer.x+=f.x;b.steer.y+=f.y}}
const l=Math.hypot(b.steer.x,b.steer.y);if(l>1){b.steer.x/=l;b.steer.y/=l}b.cruise=p.cruise/100;b.speedF=p.speed/100}});
defNode({id:'stop',cat:'act',label:'Stop',act:true,doc:'Bremsen',run:(i,p,b)=>{b.brake=1}});
defNode({id:'pen',cat:'act',label:'Pen',act:true,doc:'Stift runter/hoch/toggle, Farbe, Breite (mm)',ins:[IN('color','c')],params:[PS('action',['down','up','toggle']),PN('width',1.2,{min:.3,max:6,step:.1,u:'mm'}),PC('color','#111111')],
run:(i,p,b)=>{b.pen.c=hex2c(i.color);b.pen.w=p.width;if(p.action==='down')b.pen.down=true;else if(p.action==='up')b.pen.down=false;else b.pen.down=!b.pen.down}});
defNode({id:'led',cat:'act',label:'LED Ring',act:true,doc:'12-LED-Ring (LED 0 vorn, gegen den Uhrzeigersinn). Modi: fill progress(value 0–1) pointer(angle) segment(angle,n) spin pulse blink pattern(12 Zeichen 0–f) off',
ins:[IN('color','c'),IN('value'),IN('angle')],params:[PS('mode',['fill','progress','pointer','segment','spin','pulse','blink','pattern','off']),PC('color','#00a6a6'),PN('value',1,{min:0,max:1,step:.05}),PN('angle',0,{min:-180,max:180,u:'°'}),PN('n',3,{min:1,max:12,step:1}),PN('hz',1.5,{min:.1,max:10,step:.1}),PT('pattern','101010101010')],
run:(i,p,b)=>{const c=hex2c(i.color);b.led={mode:p.mode,c,val:+i.value||0,ang:+i.angle||0,n:p.n,hz:p.hz,pat:p.pattern};if(p.mode!=='off')b.ledMain=c}});
defNode({id:'send',cat:'act',label:'Send',act:true,doc:'Nachricht senden: radius (alle im Umkreis), direct (to = Boid-Referenz) oder all',ins:[IN('value','*'),IN('to','o')],params:[PT('channel','msg'),PS('mode',['radius','direct','all']),PN('radius',20,{u:'cm'}),PN('value',1)],
run:(i,p,b,w)=>{w.send(b,p.channel,i.value,p.mode,p.mode==='direct'?i.to:p.radius)}});
defNode({id:'setvar',cat:'act',label:'Set Var',act:true,ins:[IN('value','*')],params:[PT('name','x'),PN('value',0)],run:(i,p,b)=>{b.vars[p.name]=i.value}});
defNode({id:'goto',cat:'act',label:'→ Behaviour',act:true,doc:'Verhaltenswechsel: zu anderem Verhalten wechseln',params:[PB('to','')],run:(i,p,b)=>{if(p.to&&p.to!==b.bh)b.pend=p.to}});
defNode({id:'sleep',cat:'act',label:'Sleep',act:true,doc:'Schlafen für X Sekunden (0 = bis "wake")',params:[PN('sec',5,{min:0,u:'s'})],run:(i,p,b)=>{b.sleep=p.sec>0?p.sec:Infinity}});
/* ---------- DEBUG ---------- */
defNode({id:'probe',cat:'debug',label:'Probe',doc:'Zeichnet einen Wert auf die Bühne: Vektor als Punkt oder Richtung, Liste als Linien',ins:[IN('v','*')],params:[PS('kind',['point','dir']),PN('scale',0.1,{step:.05})],
run:(i,p,b,w)=>{if(!w.dbgOn(b,'probe'))return;const v=i.v,x=w.x[b.i],y=w.y[b.i];
if(Array.isArray(v)){for(const j of v)w.dl(x,y,w.x[j],w.y[j],'probe')}else if(isV(v)){if(p.kind==='dir')w.dl(x,y,x+v.x*p.scale,y+v.y*p.scale,'probe');else w.dx(v.x,v.y,'probe')}}});
/* ==========================================================================
VERSTÄNDLICHKEIT: deutsche Namen, Erklärtexte (Hover) und Port-Beschriftungen
Die technischen IDs/Port-Namen bleiben (für LLM, Projekte, Kabel) – nur die Anzeige ändert sich.
========================================================================== */
const CAT_DE={sense:'Sensor',event:'Auslöser',math:'Rechnen',steer:'Kraft',act:'Aktion',debug:'Debug',user:'Eigene'};
const CAT_HINT={sense:'liest etwas aus (Nachbarn, Wand, Tinte …)',event:'feuert einen Impuls (einmaliges „jetzt!“)',math:'rechnet / wandelt Werte um',steer:'erzeugt eine Kraft: „schiebe mich dorthin“',act:'tut etwas (bewegen, malen, leuchten …)',debug:'macht Werte auf der Bühne sichtbar',user:'vom LLM geschriebener Node'};
const PORT_DE={in:'Signal',out:'Ergebnis',do:'auslösen wenn',list:'Liste',weight:'Stärke',target:'Ziel',steer:'Kraft',radius:'Radius',fov:'Sichtfeld',pos:'Position',vel:'Tempo-Vektor',dir:'Richtung',speed:'Tempo',angle:'Winkel',id:'Nummer',time:'Zeit',mode:'Modus',margin:'Rand',dist:'Abstand',normal:'Wand-Normale',near:'nah?',hit:'getroffen?',point:'Treffpunkt',length:'Länge',ahead:'Abstand voraus',value:'Wert',grad:'Richtung zu mehr',here:'hier',found:'gefunden?',boid:'Boid',idx:'Regler',name:'Name',has:'vorhanden?',channel:'Kanal',hold:'Haltezeit',age:'Alter (s)',kind:'Art',period:'Intervall',desync:'versetzt',rate:'Rate',secs:'Sekunden',sec:'Sekunden',set:'setzen',reset:'löschen',color:'Farbe',h:'Farbton',s:'Sättigung',v:'Helligkeit',x:'x',y:'y',len:'Länge',a:'A',b:'B',c:'Bedingung',op:'Rechenart',inMin:'von min',inMax:'von max',outMin:'nach min',outMax:'nach max',clamp:'begrenzen',freq:'Frequenz',phase:'Phase',min:'min',max:'max',new:'neu',f1:'Kraft 1',f2:'Kraft 2',f3:'Kraft 3',f4:'Kraft 4',cruise:'Grundtempo',action:'Aktion',width:'Breite',n:'Anzahl',hz:'Hz',pattern:'Muster',to:'Ziel',look:'Vorausschau',field:'Feld',val:'Wert',txt:'Text',what:'Wovon',scale:'Maßstab',from:'Absender',left:'links'};
const OPT_DE={nearest:'nächster',farthest:'entferntester',random:'zufällig','lowest id':'niedrigste Nr.','highest id':'höchste Nr.',first:'erster',dist:'Abstand',id:'Nummer',speed:'Tempo',bearing:'Peilung',hue:'Farbton',behaviour:'Verhalten',var:'Variable',pos:'Position',vel:'Tempo',dir:'Richtung',seek:'hin zum Ziel',flee:'weg vom Ziel',arrive:'ankommen (bremst)',down:'runter',up:'hoch',toggle:'umschalten',radius:'im Umkreis',direct:'direkt an Boid',all:'an alle',fixed:'fester Punkt',mouse:'Maus',center:'Mitte',fill:'füllen',progress:'Balken',pointer:'Zeiger',segment:'Segment',spin:'drehen',pulse:'pulsieren',blink:'blinken',pattern:'Muster',off:'aus',and:'und',or:'oder',xor:'entweder-oder',not:'nicht',xy:'x / y',polar:'Winkel + Länge',add:'plus',sub:'minus',mul:'mal',div:'geteilt',min:'Minimum',max:'Maximum',pow:'hoch',mod:'Rest',atan2:'Winkel(a,b)',normalize:'Länge 1',limit:'begrenzen',setlen:'Länge setzen',rotate:'drehen (°)',dot:'Skalarprodukt',perp:'senkrecht',neg:'umkehren',len:'Länge',angle:'Winkel',point:'Punkt',free:'frei',diff:'Rad-Bot'};
const NODEINFO={
self:['Ich selbst','Alles über diesen Boid: wo er ist, wie schnell, wohin er schaut, seine Nummer und wie lange er schon im aktuellen Verhalten ist.',{pos:'Position',vel:'Tempo-Vektor',dir:'Blickrichtung',speed:'Tempo (cm/s)',angle:'Winkel (°)',id:'Nummer',time:'Zeit im Verhalten (s)'}],
neighbors:['Nachbarn finden','Findet alle anderen Boids im Umkreis (optional nur im Sichtfeld vor mir). Das Ergebnis ist eine Liste – sie wird an „Zählen“, „Auswählen“ oder die Kraft-Nodes weitergegeben.',{radius:'Umkreis (cm)',list:'Liste',fov:'Sichtfeld (°)'}],
pick:['Einen auswählen','Wählt aus einer Liste genau einen Boid: den nächsten, entferntesten, einen zufälligen … Ist keine Liste angeschlossen, sucht der Node selbst im Umkreis.',{list:'Liste',boid:'Boid',found:'gefunden?',pos:'Position',dist:'Abstand (cm)',mode:'Wahl',radius:'Suchradius (cm)'}],
filter:['Liste filtern','Behält aus einer Liste nur Boids, die eine Bedingung erfüllen – z. B. „Abstand < 10 cm“ oder „gleiches Verhalten wie ich“.',{list:'Liste',field:'Eigenschaft',op:'Vergleich',val:'Wert',txt:'Name (bei Verhalten/Variable)'}],
count:['Zählen','Wie viele Boids stehen in der Liste?',{list:'Liste',n:'Anzahl'}],
average:['Mittelwert','Durchschnitt einer Liste: wo ist ihre Mitte (Position), wohin bewegen sich alle im Schnitt (Tempo / Richtung).',{list:'Liste',v:'Mittelwert',n:'Anzahl',what:'Wovon'}],
boid:['Boid-Info','Fragt Daten eines bestimmten Boids ab (z. B. des gewählten nächsten): Position, Richtung, Abstand zu mir, LED-Farbton, Nummer.',{boid:'Boid',pos:'Position',vel:'Tempo-Vektor',dir:'Richtung',dist:'Abstand (cm)',hue:'LED-Farbton',id:'Nummer',found:'gültig?'}],
wall:['Wand','Misst den Abstand zur nächsten Wand und gibt die Richtung „weg von der Wand“ an. „nah?“ wird AN, wenn der Abstand kleiner als der Rand ist.',{dist:'Abstand (cm)',normal:'Weg von Wand',near:'nah?',margin:'Rand (cm)'}],
ray:['Sehstrahl','Schickt einen unsichtbaren Strahl in eine Richtung (relativ zur Blickrichtung) und meldet, ob und wie weit weg er eine Wand oder ein Hindernis trifft.',{hit:'getroffen?',dist:'Abstand (cm)',point:'Treffpunkt',normal:'Flächen-Normale',angle:'Winkel (°)',length:'Reichweite (cm)'}],
ink:['Tinte fühlen','Fühlt die gemalte Tinte am Boden: Wie viel liegt direkt vor mir, wie viel unter mir, und in welcher Richtung liegt am meisten?',{value:'vor mir',grad:'Richtung zu mehr Tinte',here:'unter mir',ahead:'Fühlabstand (cm)'}],
point:['Zielpunkt','Liefert einen Punkt auf der Bühne: fest, die Mausposition, die Mitte oder ein Zufallspunkt (der bei „neu“-Impuls neu gewählt wird).',{new:'neu würfeln',pos:'Position',valid:'gültig?',mode:'Art',x:'x (cm)',y:'y (cm)'}],
global:['Regler G1–G4','Liest einen der globalen Schieberegler oben (0 bis 1). So steuerst du alle Boids live, z. B. die Stärke einer Kraft.',{n:'Wert',idx:'Regler Nr.'}],
var:['Variable lesen','Liest einen Wert, den sich dieser Boid gemerkt hat (Gegenstück zu „Variable setzen“).',{value:'Wert',has:'vorhanden?',name:'Name'}],
inbox:['Posteingang','Zeigt die letzte Nachricht auf einem Kanal, solange sie noch frisch ist. Gut für Dauerwerte (z. B. „Sammelpunkt“).',{value:'Inhalt',from:'Absender',has:'frisch?',age:'Alter (s)',channel:'Kanal',hold:'bleibt gültig (s)'}],
touch:['Berührung','Wird AN, wenn der Boid im letzten Schritt etwas berührt hat: Wand (1), anderen Boid (2) oder Hindernis (3).',{hit:'berührt?',kind:'Art (1/2/3)',normal:'Richtung'}],
onenter:['Beim Start','Feuert einmal, direkt nachdem ein Boid in dieses Verhalten gewechselt ist. Ideal, um beim Wechsel den Stift oder die LED zu setzen.',{out:'Impuls'}],
every:['Alle X Sekunden','Feuert regelmäßig im festen Takt. „versetzt“ gibt jedem Boid einen eigenen Startzeitpunkt, damit nicht alle gleichzeitig feuern.',{out:'Impuls',period:'Intervall (s)',desync:'versetzt'}],
onmsg:['Bei Nachricht','Feuert einmal, wenn auf dem Kanal eine neue Nachricht ankommt – von einem anderen Boid oder global von den Knöpfen oben.',{out:'Impuls',value:'Inhalt',from:'Absender',channel:'Kanal'}],
chance:['Zufallsimpuls','Feuert zufällig – im Mittel so oft pro Sekunde, wie die Rate sagt (0.2 = etwa alle 5 s).',{out:'Impuls',rate:'Rate (pro s)'}],
rise:['Einschalt-Impuls','Macht aus einem Dauer-Signal EINEN einzelnen Impuls: Er feuert genau in dem Moment, in dem das Signal von AUS auf AN springt – danach nicht mehr, bis es zwischendurch wieder AUS war.\nBeispiel: „mehr als 7 Nachbarn“ ist dauernd AN → mit diesem Node wechselt der Boid nur ein einziges Mal das Verhalten.',{in:'Signal (AN/AUS)',out:'Impuls beim Einschalten'}],
hold:['Halten','Verlängert einen Impuls: Nach dem Auslösen bleibt der Ausgang für die eingestellte Zeit AN.',{in:'Impuls',out:'AN solange',secs:'Dauer (s)'}],
delay:['Verzögern','Gibt einen Impuls erst nach der eingestellten Zeit weiter.',{in:'Impuls',out:'Impuls später',secs:'Verzögerung (s)'}],
latch:['Merker','Speichert AN/AUS: „setzen“ schaltet an, „löschen“ schaltet aus. Dazwischen bleibt der Zustand erhalten.',{set:'setzen',reset:'löschen',out:'Zustand'}],
num:['Zahl','Eine feste Zahl, die man an andere Nodes hängen kann.',{out:'Zahl',value:'Zahl'}],
color:['Farbe','Mischt eine Farbe aus Farbton (0–360°), Sättigung und Helligkeit (0–1). Für LED-Ring und Stift.',{out:'Farbe',h:'Farbton (°)',s:'Sättigung',v:'Helligkeit'}],
vec:['Richtung bauen','Baut einen Richtungspfeil (Vektor): entweder aus x und y – oder aus Winkel (°) und Länge.',{a:'x / Winkel',b:'y / Länge',out:'Vektor',mode:'Art'}],
split:['Richtung zerlegen','Zerlegt einen Richtungspfeil in x, y, Länge und Winkel.',{v:'Vektor',x:'x',y:'y',len:'Länge',angle:'Winkel (°)'}],
math:['Rechnen','Grundrechenarten für Zahlen oder Richtungspfeile: plus, minus, mal, geteilt, Minimum … Mehrere Kräfte fasst man z. B. mit „plus“ zusammen.',{a:'A',b:'B',out:'Ergebnis',op:'Rechenart'}],
vecop:['Richtung bearbeiten','Verändert einen Richtungspfeil: auf Länge 1 bringen, kürzen, drehen, umkehren … oder seine Länge / seinen Winkel messen.',{v:'Vektor',a:'Wert',out:'Ergebnis',op:'Operation'}],
map:['Umrechnen','Rechnet einen Wertebereich in einen anderen um, z. B. „0 bis 8 Nachbarn“ → „0 bis 100 % Helligkeit“.',{in:'Eingang',out:'Ausgang',inMin:'von min',inMax:'von max',outMin:'nach min',outMax:'nach max',clamp:'begrenzen'}],
noise:['Perlin-Rauschen','Ein weich schwankender Zufallswert zwischen −1 und 1. Anders als echter Zufall springt er nicht, sondern gleitet – ideal für organische, ruhige Bewegung.',{out:'Wert −1…1',freq:'Tempo (Hz)',desync:'pro Boid anders'}],
wave:['Sinuswelle','Schwingt gleichmäßig zwischen −1 und 1. Für Pulsieren und Takte.',{out:'Wert −1…1',freq:'Tempo (Hz)',phase:'Startphase',desync:'pro Boid anders'}],
rand:['Zufallszahl','Würfelt eine Zahl zwischen min und max. Bei einem „neu“-Impuls wird neu gewürfelt, sonst bleibt der Wert stehen.',{new:'neu würfeln',out:'Zahl'}],
compare:['Vergleichen','Vergleicht zwei Zahlen (größer, kleiner, gleich …). Ergebnis: AN oder AUS.',{a:'A',b:'B',out:'Ergebnis (AN/AUS)',op:'Vergleich'}],
logic:['Logik','Verknüpft AN/AUS-Signale: und, oder, entweder-oder, nicht.',{a:'A',b:'B',out:'Ergebnis',op:'Verknüpfung'}],
select:['Wenn – dann','Wenn die Bedingung AN ist, wird A weitergegeben, sonst B.',{c:'Bedingung',a:'dann A',b:'sonst B',out:'Ergebnis'}],
seek:['Hin / weg vom Ziel','Erzeugt eine Kraft zu einem Ziel hin („hin“), von ihm weg („weg“) oder sanft abbremsend („ankommen“). Das Ziel kommt z. B. von „Zielpunkt“ oder „Einen auswählen“.',{target:'Ziel',weight:'Stärke',steer:'Kraft',mode:'Modus',radius:'Bremsradius (cm)'}],
align:['Angleichen','Kraft, die die eigene Bewegung an die der Nachbarn anpasst – so schwimmt der Schwarm in dieselbe Richtung.',{list:'Nachbarn',weight:'Stärke',steer:'Kraft'}],
separate:['Abstand halten','Kraft, die von Nachbarn wegschiebt, die näher als der Radius sind. Verhindert Zusammenstöße.',{list:'Nachbarn',weight:'Stärke',steer:'Kraft',radius:'Abstand (cm)'}],
cohere:['Zusammenbleiben','Kraft zur Mitte der Nachbarn hin. Hält den Schwarm zusammen.',{list:'Nachbarn',weight:'Stärke',steer:'Kraft'}],
wander:['Umherirren','Kraft für zufälliges, weiches Herumstreifen: ändert die Richtung langsam und zufällig.',{weight:'Stärke',steer:'Kraft',rate:'Unruhe'}],
avoidwalls:['Wand meiden','Kraft, die kurz vor einer Wand wegdreht. Je näher die Wand, desto stärker.',{weight:'Stärke',steer:'Kraft',margin:'Ab Abstand (cm)'}],
avoidobst:['Hindernis meiden','Kraft, die vor Hindernissen (Kreise/Rechtecke auf der Bühne) wegdreht.',{weight:'Stärke',steer:'Kraft',look:'Vorausschau (cm)'}],
move:['Bewegen','Hier laufen alle Kräfte zusammen: summiert Kraft 1–4 und bewegt den Boid. „Grundtempo“ ist, wie schnell er auch ganz ohne Kraft vorwärts fliegt (in % des Maximums).',{f1:'Kraft 1',f2:'Kraft 2',f3:'Kraft 3',f4:'Kraft 4',cruise:'Grundtempo (%)',speed:'Max-Tempo (%)'}],
stop:['Anhalten','Bremst den Boid ab.',{}],
pen:['Stift','Stift senken, heben oder umschalten; Farbe und Breite (mm) der Linie auf dem Boden.',{color:'Farbe',action:'Aktion',width:'Breite (mm)'}],
led:['LED-Ring','Steuert den 12-LED-Ring oben auf dem Boid: füllen, Balken (Wert 0–1), Zeiger (Winkel), drehen, pulsieren, blinken, Muster …',{color:'Farbe',value:'Wert 0–1',angle:'Winkel (°)',mode:'Modus',n:'Länge',hz:'Tempo (Hz)',pattern:'Muster (12 Stellen)'}],
send:['Nachricht senden','Schickt eine Nachricht an Boids im Umkreis, an einen bestimmten Boid oder an alle. Empfänger lesen sie mit „Bei Nachricht“ oder „Posteingang“.',{value:'Inhalt',to:'an Boid',channel:'Kanal',mode:'an wen',radius:'Umkreis (cm)'}],
setvar:['Variable setzen','Merkt sich einen Wert unter einem Namen (nur für diesen Boid). Auslesen mit „Variable lesen“.',{value:'Wert',name:'Name'}],
goto:['Verhalten wechseln','Schickt den Boid in ein anderes Verhalten. Meist wird dieser Node mit einem Impuls verbunden (z. B. „Einschalt-Impuls“), sonst wechselt der Boid sofort.',{to:'neues Verhalten'}],
sleep:['Schlafen','Boid ruht für X Sekunden (0 = bis „Wecken“ gedrückt wird).',{sec:'Sekunden'}],
probe:['Sonde','Zeichnet einen Wert auf die Bühne, damit man ihn sieht: Vektor als Punkt oder Pfeil, Liste als Linien zu den Boids.',{v:'Wert',kind:'Darstellung',scale:'Pfeillänge'}]
};
for(const id in NODEINFO){const d=DEFS[id];if(!d)continue;const[l,t,pn]=NODEINFO[id];d.label=l;d.doc=t;d.pn=pn||{}}
const portName=(d,n)=>(d&&d.pn&&d.pn[n])||PORT_DE[n]||n;
const optName=o=>OPT_DE[o]||o;
const nodeTip=d=>{const act=d.act?'\nOhne Kabel an „auslösen wenn“ läuft die Aktion ständig; mit Kabel nur, wenn dort ein Impuls ankommt.':'';
const i=d.ins.filter(x=>x.n!=='do').map(x=>portName(d,x.n)).join(', '),o=d.outs.map(x=>portName(d,x.n)).join(', ');
return{t:d.label,cat:d.cat,body:(d.doc||'')+act,io:(i?'Eingänge: '+i:'')+(i&&o?' · ':'')+(o?'Ausgänge: '+o:'')}};
/* ---------- User-Nodes (vom LLM geschrieben) ---------- */
function registerUserNode(u){
const fn=new Function('i','p','b','w','H','s',u.code);
defNode({id:u.id,cat:'user',label:u.label||u.id,doc:u.doc,ins:u.ins||[],outs:u.outs||[],params:u.params||[],act:!!u.act,user:true,
run:(i,p,b,w,s)=>{try{return fn(i,p,b,w,H,s)}catch(e){if(!u._err){u._err=e.message;W.runErr=u.id+': '+e.message}return undefined}}})}
/* ================= Presets ================= */
function mkGraph(nodes,wires){return{nodes:nodes.map(([id,type,x,y,params,ex])=>({id,type,x,y,params:{...defParams(type),...(params||{})},ex:ex||[]})),
wires:wires.map(([f,t])=>{const[fa,fo]=f.split('.'),[ta,ti]=t.split('.');return{f:fa,fo,t:ta,ti}})}}
function presetFlock(){return mkGraph([
['n1','neighbors',10,10,{radius:35,fov:300},['radius']],['n8','count',10,95],['n11','compare',10,160,{op:'≥',b:4},['b']],['n12','rise',10,290],['n13','goto',10,355,{to:'Malen'}],
['n2','align',240,10,{weight:.8},['weight']],['n3','separate',240,95,{radius:8,weight:1.4},['weight']],['n4','cohere',240,205,{weight:.6},['weight']],
['n5','avoidwalls',240,295],['n6','avoidobst',240,400],['n16','math',470,400,{op:'add'}],
['n9','map',470,10,{inMax:5}],['n14','onenter',470,175],['n15','pen',470,240,{action:'up'}],
['n7','move',700,10,{cruise:70},['cruise']],['n10','led',700,205,{mode:'progress',color:'#3fc8c8'}]],
[['n1.list','n2.list'],['n1.list','n3.list'],['n1.list','n4.list'],['n1.list','n8.list'],['n8.n','n9.in'],['n9.out','n10.value'],
['n8.n','n11.a'],['n11.out','n12.in'],['n12.out','n13.do'],['n14.out','n15.do'],
['n2.steer','n7.f1'],['n3.steer','n7.f2'],['n4.steer','n7.f3'],['n5.steer','n16.a'],['n6.steer','n16.b'],['n16.out','n7.f4']])}
function presetDraw(){return mkGraph([
['n1','wander',10,10,{rate:2.5,weight:.9},['weight']],['n2','avoidwalls',10,105],['n10','avoidobst',10,210],['n3','neighbors',10,315,{radius:10}],['n4','separate',240,315,{radius:7,weight:.9}],
['n6','pen',240,10,{action:'down',width:1.2,color:'#111111'},['width']],['n5','move',470,10,{cruise:75},['cruise']],['n7','led',470,205,{mode:'spin',color:'#ff8a1f',hz:1.2,n:4}],
['n8','every',240,150,{period:10}],['n9','goto',240,225,{to:'Flocken'}]],
[['n1.steer','n5.f1'],['n2.steer','n5.f2'],['n3.list','n4.list'],['n4.steer','n5.f3'],['n10.steer','n5.f4'],['n8.out','n9.do']])}
/* ---- einfache Demo-Verhalten ---- */
function presetBounce(){return mkGraph([
['n1','self',10,10],['n2','avoidwalls',10,200,{margin:6,weight:1.6},['margin']],['n4','avoidobst',10,290],
['n5','math',240,200,{op:'add'}],['n3','move',470,10,{cruise:80},['cruise']],['n6','led',470,170,{mode:'fill',color:'#4da3ff'}]],
[['n1.dir','n3.f1'],['n2.steer','n5.a'],['n4.steer','n5.b'],['n5.out','n3.f2']])}
function presetFollow(){return mkGraph([
['n1','pick',10,10,{mode:'nearest',radius:100}],['n3','neighbors',10,150,{radius:10}],['n5','avoidwalls',10,260],
['n2','seek',240,10,{mode:'seek',weight:1},['weight']],['n4','separate',240,150,{radius:7,weight:1.2}],
['n6','move',470,10,{cruise:85},['cruise']],['n7','led',470,170,{mode:'fill',color:'#ff5c8a'}]],
[['n1.pos','n2.target'],['n2.steer','n6.f1'],['n3.list','n4.list'],['n4.steer','n6.f2'],['n5.steer','n6.f3']])}
function presetWander(){return mkGraph([
['n1','wander',10,10,{rate:3,weight:1},['rate']],['n2','avoidwalls',10,120,{weight:2}],['n4','avoidobst',10,215],
['n3','move',240,10,{cruise:65},['cruise']],['n5','led',240,170,{mode:'fill',color:'#ffd166'}]],
[['n1.steer','n3.f1'],['n2.steer','n3.f2'],['n4.steer','n3.f3']])}
function presetPerlin(){return mkGraph([
['n1','noise',10,10,{freq:.12,desync:true},['freq']],['n2','map',230,10,{inMin:-1,inMax:1,outMin:-540,outMax:540}],['n3','vec',450,10,{mode:'polar',b:1}],
['n4','avoidwalls',450,120,{weight:2}],['n5','move',670,10,{cruise:60},['cruise']],['n6','led',670,170,{mode:'fill',color:'#6ee7b7'}]],
[['n1.out','n2.in'],['n2.out','n3.a'],['n3.out','n5.f1'],['n4.steer','n5.f2']])}
function defaultProject(){return{v:2,behaviours:{Flocken:presetFlock(),Malen:presetDraw(),Wandabprall:presetBounce(),Folgen:presetFollow(),Umherirren:presetWander(),'Perlin-Drift':presetPerlin()},init:{bh:'Flocken',n:9},sim:{...W.sim},user:[],obst:[]}}
/* ================= Projekt / Speicher ================= */
const LS='swarm.v2',LIB='swarm.lib';
let P=null,stepReq=false;
function loadProject(){try{const s=localStorage.getItem(LS);if(s){const o=JSON.parse(s);if(o&&o.behaviours&&Object.keys(o.behaviours).length)return o}}catch(e){}return defaultProject()}
let saveT=0;const saveSoon=()=>{clearTimeout(saveT);saveT=setTimeout(saveNow,400)};
function saveNow(){try{P.sim={...W.sim};P.init={bh:W.init.bh,n:W.init.n};P.obst=W.obst;localStorage.setItem(LS,JSON.stringify(P))}catch(e){console.warn(e)}}
function recompileAll(){W.plans={};for(const k in P.behaviours)W.plans[k]=compile(P.behaviours[k])}
function applyProject(o){P=o;P.user=P.user||[];P.obst=P.obst||[];Object.assign(W.sim,o.sim||{});Object.assign(W.init,o.init||{});W.obst=P.obst;
for(const u of P.user){try{registerUserNode(u)}catch(e){console.warn('user node',e)}}recompileAll()}
const esc=s=>String(s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));
const bhNames=()=>Object.keys(P.behaviours);
/* ================= Kopfleiste ================= */
function setRun(v){W.running=v;const b=$('#bPlay');b.classList.toggle('on',v);b.textContent=v?'❚❚':'▶'}
$('#bPlay').onclick=()=>setRun(!W.running);
$('#bStep').onclick=()=>{setRun(false);stepReq=true};
$('#selSpeed').onchange=e=>W.speed=+e.target.value;
$('#bSpawn').onclick=()=>{W.init.n=clamp(+$('#inN').value|0,1,400);spawn(W.init.n);onSelChange();saveSoon()};
$$('[data-gm]').forEach(b=>b.onclick=()=>{W.globalMsg(b.dataset.gm);if(b.dataset.gm==='reset')onSelChange()});
$$('[data-g]').forEach(s=>s.oninput=()=>W.G[+s.dataset.g]=+s.value);
$('#bSide').onclick=()=>{$('#main').classList.toggle('collapsed');setTimeout(resize,30)};
$('#rFade').oninput=e=>{const v=+e.target.value;W.sim.fade=v;$('#vFade').textContent=v?v+'s':'aus';saveSoon()};
/* ================= Bühne: Werkzeuge & Auswahl ================= */
let tool='select',drag=null;const wrap=$('#stageWrap'),rectEl=$('#rect');
$$('[data-tool]').forEach(b=>b.onclick=()=>{tool=b.dataset.tool;$$('[data-tool]').forEach(x=>x.classList.toggle('on',x===b))});
function pointerWorld(e){const r=wrap.getBoundingClientRect(),px=e.clientX-r.left,py=e.clientY-r.top;return{px,py,...toWorld(px,py)}}
function pickBoid(x,y){let best=-1,bd=R*1.6;for(let i=0;i<W.n;i++){const d=Math.hypot(W.x[i]-x,W.y[i]-y);if(d<bd){bd=d;best=i}}return best}
function setSel(list,add=false){if(!add){for(const i of W.sel)if(W.B[i])W.B[i].sel=false;W.sel.clear()}for(const i of list){if(W.B[i]){W.sel.add(i);W.B[i].sel=true}}
W.watch=W.sel.size?[...W.sel][0]:-1;onSelChange()}
function toggleSel(i){if(W.sel.has(i)){W.sel.delete(i);W.B[i].sel=false}else{W.sel.add(i);W.B[i].sel=true}W.watch=W.sel.size?[...W.sel][0]:-1;onSelChange()}
wrap.addEventListener('pointermove',e=>{const p=pointerWorld(e);W.mouse.x=p.x;W.mouse.y=p.y;W.mouse.in=Math.abs(p.x)<=HALF&&Math.abs(p.y)<=HALF;if(drag)dragMove(p,e)});
wrap.addEventListener('pointerleave',()=>{W.mouse.in=false});
wrap.addEventListener('pointerdown',e=>{if(e.button!==0)return;wrap.setPointerCapture(e.pointerId);const p=pointerWorld(e);
if(tool==='select'){const i=pickBoid(p.x,p.y);
if(i>=0){if(e.shiftKey)toggleSel(i);else if(!W.sel.has(i))setSel([i]);drag={k:'move',i}}
else drag={k:'box',px:p.px,py:p.py,add:e.shiftKey}}
else if(tool==='circ'||tool==='rect')drag={k:tool,x:p.x,y:p.y,px:p.px,py:p.py};
else if(tool==='erase'){const k=W.obst.findIndex(o=>obstDist(o,p.x,p.y)<=0);if(k>=0){W.obst.splice(k,1);rebuildObst();saveSoon()}}});
function dragMove(p,e){
if(drag.k==='move'){const i=drag.i;W.x[i]=clamp(p.x,-HALF+R,HALF-R);W.y[i]=clamp(p.y,-HALF+R,HALF-R);W.vx[i]=W.vy[i]=W.sp[i]=0;W.B[i].pen.was=false}
else if(drag.k==='box'||drag.k==='rect'){const x0=Math.min(drag.px,p.px),y0=Math.min(drag.py,p.py);Object.assign(rectEl.style,{display:'block',left:x0+'px',top:y0+'px',width:Math.abs(p.px-drag.px)+'px',height:Math.abs(p.py-drag.py)+'px',borderRadius:'0'})}
else if(drag.k==='circ'){const r=Math.hypot(p.px-drag.px,p.py-drag.py);Object.assign(rectEl.style,{display:'block',left:drag.px-r+'px',top:drag.py-r+'px',width:2*r+'px',height:2*r+'px',borderRadius:'50%'})}}
wrap.addEventListener('pointerup',e=>{if(!drag)return;const p=pointerWorld(e);rectEl.style.display='none';
if(drag.k==='box'){const x0=Math.min(drag.px,p.px),x1=Math.max(drag.px,p.px),y0=Math.min(drag.py,p.py),y1=Math.max(drag.py,p.py);
if(x1-x0<4&&y1-y0<4){if(!drag.add)setSel([])}else{const ids=[];for(let i=0;i<W.n;i++){const s=toScreen(W.x[i],W.y[i]);if(s.x>=x0&&s.x<=x1&&s.y>=y0&&s.y<=y1)ids.push(i)}setSel(ids,drag.add)}}
else if(drag.k==='circ'){const q=toWorld(drag.px,drag.py),r=Math.hypot(p.x-q.x,p.y-q.y);if(r>0.01){W.obst.push({t:'c',x:q.x,y:q.y,r});rebuildObst();saveSoon()}}
else if(drag.k==='rect'){const x0=Math.min(drag.x,p.x),x1=Math.max(drag.x,p.x),y0=Math.min(drag.y,p.y),y1=Math.max(drag.y,p.y);if(x1-x0>0.01&&y1-y0>0.01){W.obst.push({t:'r',x0,x1,y0,y1});rebuildObst();saveSoon()}}
drag=null});
/* ---- Auswahlleiste ---- */
$('#selBar').innerHTML=`<span id="selTxt" class="mono mut">nichts gewählt</span><span class="sp"></span><span class="mut">Verhalten zuweisen:</span><select id="selBh"></select><button id="selApply">→ Auswahl</button><button id="selAll">→ alle</button><input id="selPct" type="number" min="1" max="100" value="20" style="width:42px"><span class="mut">%</span><button id="selRand">→ zufällig</button>`;
function fillBhSelect(sel,keep){const v=keep??sel.value;sel.innerHTML=bhNames().map(n=>`<option>${esc(n)}</option>`).join('');if(bhNames().includes(v))sel.value=v}
function assignBh(list,name){for(const i of list)if(W.B[i])setBh(W.B[i],name);onSelChange()}
$('#selApply').onclick=()=>assignBh([...W.sel],$('#selBh').value);
$('#selAll').onclick=()=>assignBh([...Array(W.n).keys()],$('#selBh').value);
$('#selRand').onclick=()=>{const k=Math.round(W.n*clamp(+$('#selPct').value,1,100)/100),ids=[...Array(W.n).keys()].sort(()=>W.rand()-.5).slice(0,k);assignBh(ids,$('#selBh').value)};
function updateSelBar(){const t=$('#selTxt'),n=W.sel.size;if(!n)t.textContent='nichts gewählt (Klick / Rahmen ziehen)';else if(n===1){const b=W.B[W.watch];t.textContent=`#${b.id} · ${b.bh}${b.sleep>0?' · schläft':''}`}else t.textContent=`${n} gewählt`}
function onSelChange(){updateSelBar();buildBoidPane()}
/* ================= Tabs ================= */
$$('#tabs button').forEach(b=>b.onclick=()=>{$$('#tabs button').forEach(x=>x.classList.toggle('on',x===b));$$('.pane').forEach(p=>p.classList.toggle('on',p.id===b.dataset.pane));if(b.dataset.pane==='pGraph')requestAnimationFrame(()=>drawWires());if(b.dataset.pane==='pBoid')buildBoidPane()});
const graphVisible=()=>$('#pGraph').classList.contains('on');
/* ================= Boid-Inspektor ================= */
function buildBoidPane(){const box=$('#boidInfo');
if(!W.sel.size){box.innerHTML='<div class="mut">Boid auf der Bühne anklicken (Shift = mehrere, Rahmen ziehen = Bereich).<br>Parameter, die du im Graph mit ◉ freigibst, erscheinen hier als Regler pro Boid.</div>';return}
const b=W.B[W.watch];if(!b)return;
box.innerHTML=`<h4>Boid #${b.id}${W.sel.size>1?` (+${W.sel.size-1})`:''}</h4>
<div class="row"><label>Verhalten</label><select id="bpBh"></select></div>
<div class="row"><label>Zustand</label><button id="bpPen">Stift ↕</button><button id="bpSleep">schlafen ↕</button></div>
<pre id="bpLive"></pre>
<h4>Freigegebene Parameter</h4><div id="bpEx"></div>
<div class="row"><button id="bpAllRole">Werte → alle mit diesem Verhalten</button><button id="bpReset">Einzelwerte zurücksetzen</button></div>`;
const sel=$('#bpBh');fillBhSelect(sel,b.bh);sel.onchange=()=>assignBh([...W.sel],sel.value);
$('#bpPen').onclick=()=>{for(const i of W.sel)W.B[i].pen.down=!W.B[i].pen.down};
$('#bpSleep').onclick=()=>{for(const i of W.sel){const q=W.B[i];q.sleep=q.sleep>0?0:Infinity}onSelChange()};
const g=P.behaviours[b.bh],ex=$('#bpEx');let any=false;
if(g)for(const n of g.nodes){const d=DEFS[n.type];if(!d)continue;for(const name of n.ex||[]){const pd=d.params.find(x=>x.n===name);if(!pd||pd.t!=='n')continue;any=true;
const cur=b.ov?.[n.id]?.[name]??n.params[name]??pd.d,row=document.createElement('div');row.className='row';
row.innerHTML=`<label>${esc(d.label)} · ${esc(portName(d,name))}</label>`;
const r=document.createElement('input');r.type='range';r.min=pd.min??0;r.max=pd.max??Math.max(10,cur*3);r.step=pd.step??((pd.max-pd.min)>20?1:.05);r.value=cur;
const v=document.createElement('span');v.className='v mono';v.textContent=cur;
r.oninput=()=>{v.textContent=r.value;for(const i of W.sel){const q=W.B[i];if(q.bh!==b.bh)continue;(q.ov??={});(q.ov[n.id]??={})[name]=+r.value}};
row.append(r,v);ex.appendChild(row)}}
if(!any)ex.innerHTML='<div class="mut">keine — im Graph den Punkt ◉ neben einem Zahlenparameter anklicken</div>';
$('#bpAllRole').onclick=()=>{const src=b.ov;if(!src)return;for(const q of W.B)if(q.bh===b.bh)q.ov=JSON.parse(JSON.stringify(src))};
$('#bpReset').onclick=()=>{for(const i of W.sel)W.B[i].ov=null;buildBoidPane()}}
function updateBoidPane(){const el=$('#bpLive');if(!el||W.watch<0)return;const b=W.B[W.watch];if(!b)return;const i=b.i;
const inb=Object.entries(b.inbox).map(([k,m])=>`${k}=${typeof m.v==='object'?'…':m.v}@${(W.t-m.t).toFixed(1)}s`).join(' ')||'–';
el.textContent=`pos ${(W.x[i]*100).toFixed(1)}, ${(W.y[i]*100).toFixed(1)} cm\nTempo ${(Math.hypot(W.vx[i],W.vy[i])*100).toFixed(1)} cm/s Kurs ${(W.h[i]*180/Math.PI).toFixed(0)}°\nsteer ${b.steer.x.toFixed(2)}, ${b.steer.y.toFixed(2)} cruise ${(b.cruise*100)|0}%\nVerhalten ${b.bh} seit ${b.bt.toFixed(1)}s\nStift ${b.pen.down?'unten':'oben'} Schlaf ${b.sleep>0?(isFinite(b.sleep)?b.sleep.toFixed(1)+'s':'∞'):'–'}\nvars ${JSON.stringify(b.vars)}\ninbox ${inb}`}
/* ================= Debug- & Sim-Panel ================= */
{const labels={vel:'Geschwindigkeit',steer:'Kraft-Summe',rad:'Wahrnehmungsradius',nbr:'Nachbar-Linien',tgt:'Ziele',ray:'Sehstrahlen',msg:'Nachrichten',id:'Nr. + Verhalten',probe:'Sonden-Nodes'};
$('#dChecks').innerHTML=Object.keys(labels).map(k=>`<div class="chk"><input type="checkbox" id="dc_${k}" ${D.on[k]?'checked':''}><label for="dc_${k}">${labels[k]}</label></div>`).join('');
Object.keys(labels).forEach(k=>$('#dc_'+k).onchange=e=>D.on[k]=e.target.checked);
$('#dScope').onchange=e=>D.scope=e.target.value}
const SIMSPEC=[['h','Physik'],['drive','Antrieb','s',['free','diff']],['maxSpeed','max. Tempo (cm/s)','n',2,40,.5],['maxAccel','max. Beschl. (cm/s²)','n',5,200,1],['maxTurn','max. Drehrate (rad/s)','n',1,15,.5],['friction','Reibung','n',0,3,.05],['rest','Stoß-Elastizität','n',0,1,.05],['spread','Streuung ±% (beim Spawn)','n',0,50,1],
['h','Kommunikation'],['latency','Latenz (Ticks)','n',0,30,1],['loss','Verlust (%)','n',0,100,1]];
function buildSim(){const box=$('#simUI');let h='';
for(const s of SIMSPEC){if(s[0]==='h'){h+=`<h4>${s[1]}</h4>`;continue}
if(s[2]==='s')h+=`<div class="row"><label>${s[1]}</label><select data-sim="${s[0]}">${s[3].map(o=>`<option ${W.sim[s[0]]===o?'selected':''}>${o}</option>`).join('')}</select><span class="mut">free = klassische Boids (holonom) · diff = Rad-Bot</span></div>`;
else h+=`<div class="row"><label>${s[1]}</label><input type="range" data-sim="${s[0]}" min="${s[3]}" max="${s[4]}" step="${s[5]}" value="${W.sim[s[0]]}"><span class="v mono" data-simv="${s[0]}">${W.sim[s[0]]}</span></div>`}
h+=`<h4>Start</h4><div class="row"><label>Start-Verhalten</label><select id="initBh"></select></div><div class="row"><label>Seed</label><input type="number" id="seedIn" value="7" style="width:70px"><button id="respawn">neu starten</button></div>
<h4>Broadcast</h4><div class="row"><input type="text" id="bcCh" placeholder="Kanal (z. B. go oder bh:Malen)" style="width:190px"><input type="text" id="bcVal" placeholder="Wert" style="width:70px"><button id="bcSend">senden</button></div>
<div class="mut">Kanal <b>bh:NAME</b> schickt alle in das Verhalten NAME. Alle Boids sehen den Kanal in <i>On Message</i> / <i>Inbox</i>.</div>
<h4>Projekt</h4><div class="row"><button id="pjExport">exportieren</button><button id="pjImport">importieren</button><button id="pjObst">Hindernisse löschen</button><button id="pjReset">Projekt zurücksetzen</button></div><input type="file" id="pjFile" accept=".json" hidden>`;
box.innerHTML=h;fillBhSelect($('#initBh'),W.init.bh);
$$('[data-sim]').forEach(el=>el.oninput=()=>{const k=el.dataset.sim;W.sim[k]=el.tagName==='SELECT'?el.value:+el.value;const v=$(`[data-simv="${k}"]`);if(v)v.textContent=el.value;saveSoon()});
$('#initBh').onchange=e=>{W.init.bh=e.target.value;saveSoon()};
$('#respawn').onclick=()=>{W.seed(+$('#seedIn').value|0);spawn(W.init.n);onSelChange()};
$('#bcSend').onclick=()=>{const c=$('#bcCh').value.trim();if(c){const v=$('#bcVal').value;W.globalMsg(c,v===''?1:isNaN(+v)?v:+v)}};
$('#pjExport').onclick=()=>{saveNow();const a=document.createElement('a');a.href=URL.createObjectURL(new Blob([JSON.stringify(P,null,1)],{type:'application/json'}));a.download='swarm_project.json';a.click()};
$('#pjImport').onclick=()=>$('#pjFile').click();
$('#pjFile').onchange=async e=>{const f=e.target.files[0];if(!f)return;try{const o=JSON.parse(await f.text());if(!o.behaviours)throw 0;loadInto(o)}catch(x){alert('Ungültige Projektdatei')}};
$('#pjObst').onclick=()=>{W.obst.length=0;rebuildObst();saveSoon()};
$('#pjReset').onclick=()=>{if(confirm('Alles auf Preset zurücksetzen?'))loadInto(defaultProject())}}
function loadInto(o){applyProject(o);rebuildObst();ED.cur=bhNames()[0];renderGraph();$('#inN').value=W.init.n;$('#rFade').value=W.sim.fade;$('#vFade').textContent=W.sim.fade?W.sim.fade+'s':'aus';spawn(W.init.n);buildSim();refreshAll();saveSoon()}
/* ================= Node-Editor ================= */
const ED={cur:null,sel:new Set(),view:{x:20,y:20,z:.9},clip:null,hist:[]};
const gv=$('#graphView'),gw=$('#gWorld'),svg=$('#gWires'),stat=$('#gStat'),NS='http://www.w3.org/2000/svg';
const curG=()=>P.behaviours[ED.cur];
const CATS=['sense','event','math','steer','act','debug','user'];
const nextId=g=>'n'+(1+g.nodes.reduce((m,n)=>Math.max(m,parseInt(String(n.id).replace(/\D/g,''))||0),0));
function pushHist(){ED.hist.push(JSON.stringify(curG()));if(ED.hist.length>60)ED.hist.shift()}
function touch(){W.plans[ED.cur]=compile(curG());W.runErr=null;refreshStat();saveSoon()}
function refreshStat(){const pl=W.plans[ED.cur],msg=(pl&&pl.err)||W.runErr||'';stat.className=msg?'bad':'';stat.textContent=msg||'Rechtsklick / Doppelklick: Node hinzufügen · Ports ziehen · ◉ gibt Parameter im Boid-Panel frei · Doppelklick auf Kabel löscht'}
function applyView(){const v=ED.view;gw.style.transform=`translate(${v.x}px,${v.y}px) scale(${v.z})`}
function clientToGraph(cx,cy){const r=gv.getBoundingClientRect(),v=ED.view;return{x:(cx-r.left-v.x)/v.z,y:(cy-r.top-v.y)/v.z}}
function portPos(id,port,isOut){const el=gw.querySelector(`.node[data-id="${id}"] .pt.${isOut?'out':'in'}[data-p="${port}"]`);if(!el)return null;
const r=el.getBoundingClientRect(),g=gw.getBoundingClientRect(),z=ED.view.z;return{x:(r.left+r.width/2-g.left)/z,y:(r.top+r.height/2-g.top)/z}}
const bez=(a,b)=>{const dx=Math.max(40,Math.abs(b.x-a.x)/2);return`M${a.x},${a.y} C${a.x+dx},${a.y} ${b.x-dx},${b.y} ${b.x},${b.y}`};
function widget(n,pd){const v=n.params[pd.n]??pd.d;let el;
if(pd.t==='n'){el=document.createElement('input');el.type='number';el.value=+(+v).toFixed(3);if(pd.min!==undefined)el.min=pd.min;if(pd.max!==undefined)el.max=pd.max;el.step=pd.step??(Number.isInteger(pd.d)&&pd.d>=5?1:.1);
el.onchange=()=>{pushHist();n.params[pd.n]=+el.value;touch()}}
else if(pd.t==='s'||pd.t==='bh'){el=document.createElement('select');const opts=pd.t==='bh'?['',...bhNames()]:pd.o;el.innerHTML=opts.map(o=>`<option value="${esc(o)}" ${String(o)===String(v)?'selected':''}>${esc(o?optName(o):'—')}</option>`).join('');
el.onchange=()=>{pushHist();n.params[pd.n]=el.value;touch()}}
else if(pd.t==='k'){el=document.createElement('input');el.type='checkbox';el.checked=!!v;el.onchange=()=>{pushHist();n.params[pd.n]=el.checked;touch()}}
else if(pd.t==='c'){el=document.createElement('input');el.type='color';el.value=/^#[0-9a-f]{6}$/i.test(v)?v:'#111111';el.onchange=()=>{pushHist();n.params[pd.n]=el.value;touch()}}
else{el=document.createElement('input');el.type='text';el.value=v??'';el.onchange=()=>{pushHist();n.params[pd.n]=el.value;touch()}}
el.title=(PORT_DE[pd.n]||pd.n)+(pd.u?` (${pd.u})`:'');return el}
function exDot(n,pd){if(pd.t!=='n')return null;const e=document.createElement('i');e.className='ex'+((n.ex||[]).includes(pd.n)?' on':'');e.title='Als Regler pro Boid im Boid-Tab freigeben';
e.onclick=()=>{pushHist();n.ex=n.ex||[];const k=n.ex.indexOf(pd.n);if(k<0)n.ex.push(pd.n);else n.ex.splice(k,1);e.classList.toggle('on',k<0);touch();buildBoidPane()};return e}
function nodeEl(n,g){const d=DEFS[n.type],el=document.createElement('div');el.dataset.id=n.id;el.style.left=n.x+'px';el.style.top=n.y+'px';
el.className='node cat-'+(d?d.cat:'user')+(ED.sel.has(n.id)?' sel':'');
if(!d){el.classList.add('err');el.innerHTML=`<div class="nh"><span>? ${esc(n.type)}</span><i class="nx">×</i></div>`;return el}
const hd=document.createElement('div');hd.className='nh';hd.innerHTML=`<span>${esc(d.label)}</span><i class="nx">×</i>`;el.appendChild(hd);
const body=document.createElement('div');body.className='nb';el.appendChild(body);
const pby=Object.fromEntries(d.params.map(p=>[p.n,p])),conn=new Set(g.wires.filter(w=>w.t===n.id).map(w=>w.ti)),rows=Math.max(d.ins.length,d.outs.length);
for(let k=0;k<rows;k++){const r=document.createElement('div');r.className='r';const L=document.createElement('div');L.className='l';const Rr=document.createElement('div');Rr.className='rr';
const ip=d.ins[k];if(ip){L.innerHTML=`<span class="pt in" data-p="${ip.n}" data-t="${ip.t}"></span><span class="pl">${esc(portName(d,ip.n))}</span>`;const pd=pby[ip.n];
if(pd&&!conn.has(ip.n)){L.appendChild(widget(n,pd));const x=exDot(n,pd);if(x)L.appendChild(x)}}
const op=d.outs[k];if(op)Rr.innerHTML=`<span class="val" data-v="${k}"></span><span class="pl">${esc(portName(d,op.n))}</span><span class="pt out" data-p="${op.n}" data-t="${op.t}"></span>`;
r.append(L,Rr);body.appendChild(r)}
for(const pd of d.params){if(d.ins.some(x=>x.n===pd.n))continue;const r=document.createElement('div');r.className='r';const L=document.createElement('div');L.className='l';
L.innerHTML=`<span class="pl">${esc(portName(d,pd.n))}</span>`;L.appendChild(widget(n,pd));if(pd.u){const u=document.createElement('span');u.className='mut';u.textContent=pd.u;L.appendChild(u)}const x=exDot(n,pd);if(x)L.appendChild(x);r.appendChild(L);body.appendChild(r)}
return el}
function renderGraph(){gw.querySelectorAll('.node').forEach(n=>n.remove());const g=curG(),fresh=!g.view;ED.view=g.view||(g.view={x:20,y:20,z:.9});applyView();
for(const n of g.nodes)gw.appendChild(nodeEl(n,g));if(fresh&&g.nodes.length&&gv.clientWidth>50)fitView();else drawWires();refreshStat()}
function drawWires(){svg.innerHTML='';const g=curG();if(!g)return;
for(const w of g.wires){const a=portPos(w.f,w.fo,true),b=portPos(w.t,w.ti,false);if(!a||!b)continue;const p=document.createElementNS(NS,'path');p.setAttribute('d',bez(a,b));
const fd=DEFS[(g.nodes.find(n=>n.id===w.f)||{}).type];if(fd?.outs.find(o=>o.n===w.fo)?.t==='b')p.setAttribute('class','ev');
p.ondblclick=()=>{pushHist();g.wires=g.wires.filter(x=>!(x.t===w.t&&x.ti===w.ti));touch();renderGraph()};svg.appendChild(p)}}
const updateSelClasses=()=>gw.querySelectorAll('.node').forEach(el=>el.classList.toggle('sel',ED.sel.has(el.dataset.id)));
function connect(f,fo,t,ti){const g=curG();g.wires=g.wires.filter(w=>!(w.t===t&&w.ti===ti));g.wires.push({f,fo,t,ti})}
function addNode(type,x,y,params){const g=curG();pushHist();const id=nextId(g);g.nodes.push({id,type,x:Math.round(x),y:Math.round(y),params:{...defParams(type),...(params||{})},ex:[]});touch();renderGraph();return id}
function removeNodes(ids){const g=curG();pushHist();g.nodes=g.nodes.filter(n=>!ids.has(n.id));g.wires=g.wires.filter(w=>!ids.has(w.f)&&!ids.has(w.t));ED.sel.clear();touch();renderGraph()}
/* ---- Interaktion ---- */
gv.addEventListener('pointerdown',e=>{if(e.button!==0)return;const t=e.target;if(t.closest('#addMenu'))return;hideMenu();if(t.closest('input,select,textarea,button'))return;
const pt=t.closest('.pt'),nx=t.closest('.nx'),nh=t.closest('.nh'),node=t.closest('.node');
if(nx){removeNodes(new Set([node.dataset.id]));return}
if(pt){startWire(pt,e);return}
if(nh){startNodeDrag(node,e);return}
if(!node)startPan(e)});
function startPan(e){const v=ED.view,sx=e.clientX,sy=e.clientY,ox=v.x,oy=v.y;gv.classList.add('pan');if(!e.shiftKey){ED.sel.clear();updateSelClasses()}
const mv=ev=>{v.x=ox+ev.clientX-sx;v.y=oy+ev.clientY-sy;applyView()},up=()=>{document.removeEventListener('pointermove',mv);document.removeEventListener('pointerup',up);gv.classList.remove('pan');saveSoon()};
document.addEventListener('pointermove',mv);document.addEventListener('pointerup',up)}
function startNodeDrag(node,e){const id=node.dataset.id,g=curG();
if(!ED.sel.has(id)){if(!e.shiftKey)ED.sel.clear();ED.sel.add(id)}else if(e.shiftKey)ED.sel.delete(id);updateSelClasses();
const start=[...ED.sel].map(i=>{const n=g.nodes.find(x=>x.id===i);return{n,x:n.x,y:n.y,el:gw.querySelector(`.node[data-id="${i}"]`)}}),sx=e.clientX,sy=e.clientY;let moved=false;
const mv=ev=>{const dx=(ev.clientX-sx)/ED.view.z,dy=(ev.clientY-sy)/ED.view.z;if(!moved&&Math.hypot(dx,dy)>3){moved=true;pushHist()}if(!moved)return;
for(const s of start){s.n.x=Math.round(s.x+dx);s.n.y=Math.round(s.y+dy);s.el.style.left=s.n.x+'px';s.el.style.top=s.n.y+'px'}drawWires()},
up=()=>{document.removeEventListener('pointermove',mv);document.removeEventListener('pointerup',up);if(moved)saveSoon()};
document.addEventListener('pointermove',mv);document.addEventListener('pointerup',up)}
function startWire(pt,e){const g=curG(),node=pt.closest('.node'),id=node.dataset.id,isOut=pt.classList.contains('out'),port=pt.dataset.p;let from;
if(isOut)from={id,port};else{const w=g.wires.find(w=>w.t===id&&w.ti===port);if(!w)return;pushHist();g.wires=g.wires.filter(x=>x!==w);from={id:w.f,port:w.fo};drawWires()}
const a=portPos(from.id,from.port,true);if(!a)return;const tmp=document.createElementNS(NS,'path');tmp.setAttribute('class','tmp');svg.appendChild(tmp);
const mv=ev=>tmp.setAttribute('d',bez(a,clientToGraph(ev.clientX,ev.clientY))),
up=ev=>{document.removeEventListener('pointermove',mv);document.removeEventListener('pointerup',up);tmp.remove();
const t=document.elementFromPoint(ev.clientX,ev.clientY),tp=t&&t.closest&&t.closest('.pt.in');
if(tp){const tid=tp.closest('.node').dataset.id;if(tid!==from.id){if(isOut)pushHist();connect(from.id,from.port,tid,tp.dataset.p)}}touch();renderGraph()};
document.addEventListener('pointermove',mv);document.addEventListener('pointerup',up);mv(e)}
gv.addEventListener('wheel',e=>{e.preventDefault();const v=ED.view,r=gv.getBoundingClientRect(),mx=e.clientX-r.left,my=e.clientY-r.top,nz=clamp(v.z*Math.exp(-e.deltaY*.0015),.3,2),f=nz/v.z;
v.x=mx-(mx-v.x)*f;v.y=my-(my-v.y)*f;v.z=nz;applyView();drawWires()},{passive:false});
function fitView(){const g=curG();if(!g.nodes.length)return;const xs=g.nodes.map(n=>n.x),ys=g.nodes.map(n=>n.y),x0=Math.min(...xs),x1=Math.max(...xs)+206,y0=Math.min(...ys),y1=Math.max(...ys)+230,r=gv.getBoundingClientRect();
const z=clamp(Math.min(r.width/(x1-x0+40),r.height/(y1-y0+40)),.3,1.1);ED.view.z=z;ED.view.x=20-x0*z;ED.view.y=20-y0*z;applyView();drawWires()}
$('#gFit').onclick=fitView;
$('#gUndo').onclick=()=>undo();
function undo(){const s=ED.hist.pop();if(!s)return;P.behaviours[ED.cur]=JSON.parse(s);ED.sel.clear();touch();renderGraph()}
/* ---- Add-Menü ---- */
const menu=$('#addMenu'),mIn=menu.querySelector('input'),mList=$('#addList');let mItems=[],mSel=0,mPos={x:0,y:0};
const hideMenu=()=>{menu.style.display='none'};
function openMenu(cx,cy){const r=gv.getBoundingClientRect();mPos=clientToGraph(cx,cy);menu.style.display='block';menu.style.left=Math.max(4,Math.min(cx-r.left,r.width-250))+'px';menu.style.top=Math.max(4,Math.min(cy-r.top,r.height-320))+'px';mIn.value='';fillMenu('');mIn.focus()}
function fillMenu(q){q=q.toLowerCase().trim();mItems=Object.values(DEFS).filter(d=>!q||(d.id+' '+d.label+' '+d.cat+' '+(CAT_DE[d.cat]||'')).toLowerCase().includes(q)).sort((a,b)=>CATS.indexOf(a.cat)-CATS.indexOf(b.cat)||a.label.localeCompare(b.label));mSel=0;mList.innerHTML='';
mItems.forEach((d,k)=>{const div=document.createElement('div');if(k===0)div.className='on';div.innerHTML=`${esc(d.label)}<span>${CAT_DE[d.cat]||d.cat}</span>`;div.dataset.node=d.id;div.style.setProperty('--c',`var(--c-${d.cat})`);div.onmousedown=e=>{e.preventDefault();pickMenu(k)};mList.appendChild(div)})}
function pickMenu(k){const d=mItems[k];hideMenu();if(d)addNode(d.id,mPos.x,mPos.y)}
mIn.oninput=()=>fillMenu(mIn.value);
mIn.onkeydown=e=>{if(e.key==='Escape'){hideMenu();return}if(e.key==='Enter'){pickMenu(mSel);return}
if(e.key==='ArrowDown'||e.key==='ArrowUp'){e.preventDefault();mSel=clamp(mSel+(e.key==='ArrowDown'?1:-1),0,mItems.length-1);[...mList.children].forEach((c,k)=>c.classList.toggle('on',k===mSel));mList.children[mSel]?.scrollIntoView({block:'nearest'})}};
gv.addEventListener('contextmenu',e=>{e.preventDefault();openMenu(e.clientX,e.clientY)});
gv.addEventListener('dblclick',e=>{if(e.target===gv||e.target===gw||e.target.id==='gWires')openMenu(e.clientX,e.clientY)});
/* ---- Tastatur ---- */
document.addEventListener('keydown',e=>{const tag=(e.target.tagName||'').toLowerCase();if(tag==='input'||tag==='textarea'||tag==='select')return;
if(e.code==='Space'){e.preventDefault();setRun(!W.running);return}
if((e.key==='s'||e.key==='S')&&!e.ctrlKey&&!e.metaKey){setRun(false);stepReq=true;return}
if(!graphVisible())return;const g=curG(),mod=e.ctrlKey||e.metaKey;
if(e.key==='Delete'||e.key==='Backspace'){if(ED.sel.size){e.preventDefault();removeNodes(new Set(ED.sel))}}
else if(mod&&e.key==='z'){e.preventDefault();undo()}
else if(mod&&e.key==='c'){const ids=ED.sel;ED.clip={nodes:g.nodes.filter(n=>ids.has(n.id)).map(n=>JSON.parse(JSON.stringify(n))),wires:g.wires.filter(w=>ids.has(w.f)&&ids.has(w.t)).map(w=>({...w}))}}
else if(mod&&(e.key==='v'||e.key==='d')){if(e.key==='d'){e.preventDefault();ED.clip={nodes:g.nodes.filter(n=>ED.sel.has(n.id)).map(n=>JSON.parse(JSON.stringify(n))),wires:g.wires.filter(w=>ED.sel.has(w.f)&&ED.sel.has(w.t)).map(w=>({...w}))}}
if(!ED.clip||!ED.clip.nodes.length)return;pushHist();const map={};ED.sel.clear();
for(const n of ED.clip.nodes){const id=nextId(g);map[n.id]=id;g.nodes.push({...JSON.parse(JSON.stringify(n)),id,x:n.x+30,y:n.y+30});ED.sel.add(id)}
for(const w of ED.clip.wires)g.wires.push({f:map[w.f],fo:w.fo,t:map[w.t],ti:w.ti});touch();renderGraph()}});
/* ---- Alle-Auswahl, Legende, Hover-Erklärungen ---- */
function fillAllBh(){const s=$('#allBh');if(!s)return;s.innerHTML='<option value="">Alle → Verhalten…</option>'+bhNames().map(n=>`<option>${esc(n)}</option>`).join('')}
$('#allBh').onchange=e=>{const v=e.target.value;if(v)W.globalMsg('bh:'+v);e.target.value=''};
$('#legend').innerHTML=CATS.filter(c=>c!=='user').map(c=>`<span data-cat="${c}" style="--c:var(--c-${c})">${CAT_DE[c]}</span>`).join('');
const TYPE_DE={n:'Zahl',v:'Richtungspfeil (Vektor)',l:'Liste von Boids',o:'ein einzelner Boid',b:'AN/AUS bzw. Impuls',c:'Farbe','*':'beliebiger Wert'};
const tipEl=$('#tip');
function showTip(info,x,y){tipEl.style.setProperty('--c',`var(--c-${info.cat})`);
tipEl.innerHTML=`<b><u></u>${esc(info.t)}${info.cat?` <span class="mut" style="font-weight:400">· ${CAT_DE[info.cat]||info.cat}</span>`:''}</b>${esc(info.body).replace(/\n/g,'<br>')}${info.io?`<i>${esc(info.io)}</i>`:''}`;
tipEl.style.display='block';const w=tipEl.offsetWidth,h=tipEl.offsetHeight;tipEl.style.left=Math.max(4,Math.min(x+14,innerWidth-w-6))+'px';tipEl.style.top=Math.max(4,Math.min(y+16,innerHeight-h-6))+'px'}
const hideTip=()=>{tipEl.style.display='none'};
function tipFor(t){
const li=t.closest('#addList div');if(li&&DEFS[li.dataset.node])return nodeTip(DEFS[li.dataset.node]);
const lg=t.closest('#legend span');if(lg)return{t:CAT_DE[lg.dataset.cat],cat:lg.dataset.cat,body:'Node-Typ: '+CAT_HINT[lg.dataset.cat]};
const nodeEl=t.closest('#gWorld .node');if(!nodeEl)return null;const n=curG().nodes.find(x=>x.id===nodeEl.dataset.id),d=n&&DEFS[n.type];if(!d)return null;
const pt=t.closest('.pt');if(pt){const out=pt.classList.contains('out');return{t:portName(d,pt.dataset.p),cat:d.cat,body:(out?'Ausgang':'Eingang')+' · '+(TYPE_DE[pt.dataset.t]||pt.dataset.t)+(pt.dataset.p==='do'?'\nOhne Kabel: läuft ständig. Mit Kabel: nur bei Impuls.':'')+(out?'\nZiehen, um ein Kabel zu einem Eingang zu legen.':'')}}
if(t.closest('.nh'))return nodeTip(d);
return null}
let tipT=0,tipLast=null;
document.addEventListener('mouseover',e=>{clearTimeout(tipT);const t=e.target;if(!t.closest)return;tipT=setTimeout(()=>{const i=tipFor(t);if(i)showTip(i,e.clientX,e.clientY);else hideTip()},t.closest('#addList')?0:350)});
document.addEventListener('mouseout',e=>{clearTimeout(tipT);hideTip()});
document.addEventListener('pointerdown',()=>{clearTimeout(tipT);hideTip()},true);
/* ---- Live-Werte ---- */
function fmtVal(v){if(v===undefined)return'–';if(typeof v==='boolean')return v?'●':'○';if(typeof v==='number')return Number.isFinite(v)?(Math.abs(v)>=100?v.toFixed(0):String(+v.toFixed(2))):'∞';
if(isV(v))return v.x.toFixed(2)+','+v.y.toFixed(2);if(isC(v))return c2hex(v);if(Array.isArray(v))return'['+v.length+']';return String(v).slice(0,10)}
let liveOn=false;
setInterval(()=>{const on=$('#dLive').checked&&graphVisible()&&W.watch>=0&&W.B[W.watch]&&W.B[W.watch].bh===ED.cur;
if(!on){if(liveOn){gw.querySelectorAll('.val').forEach(s=>s.textContent='');gw.querySelectorAll('.fire').forEach(s=>s.classList.remove('fire'));liveOn=false}return}
liveOn=true;const g=curG();
for(const el of gw.querySelectorAll('.node')){const sn=W.snap[el.dataset.id],n=g.nodes.find(x=>x.id===el.dataset.id),d=n&&DEFS[n.type];let fire=false;
el.querySelectorAll('.val').forEach(s=>{const v=sn?sn[+s.dataset.v]:undefined;s.textContent=fmtVal(v);if(d&&d.cat==='event'&&v===true)fire=true});
el.classList.toggle('fire',!!fire)}},150);
/* ---- Verhaltens-Tabs ---- */
function renderTabs(){const box=$('#bhTabs');box.innerHTML='';
for(const name of bhNames()){const t=document.createElement('span');t.className='bt'+(name===ED.cur?' on':'');t.innerHTML=`<span>${esc(name)}</span>`+(name===ED.cur?'<i data-a="dup" title="duplizieren">⧉</i><i data-a="del" title="löschen">×</i>':'');
t.onclick=e=>{const a=e.target.dataset.a;if(a==='dup')dupBh(name);else if(a==='del')delBh(name);else switchBh(name)};t.ondblclick=e=>{if(!e.target.dataset.a)renameBh(name)};box.appendChild(t)}}
function switchBh(name){ED.cur=name;ED.sel.clear();ED.hist=[];renderTabs();renderGraph();refreshAll(true)}
function uniqueName(base){let n=base,k=2;while(P.behaviours[n])n=base+'_'+k++;return n}
function addBh(name,graph){name=uniqueName(name||'neu');P.behaviours[name]=graph||{nodes:[],wires:[],view:{x:20,y:20,z:.9}};W.plans[name]=compile(P.behaviours[name]);switchBh(name);saveSoon();return name}
$('#bhAdd').onclick=()=>{const n=prompt('Name des neuen Verhaltens');if(n&&n.trim())addBh(n.trim())};
function dupBh(name){addBh(name+'_copy',JSON.parse(JSON.stringify(P.behaviours[name])))}
function delBh(name){if(bhNames().length<2||!confirm(`Verhalten "${name}" löschen?`))return;delete P.behaviours[name];delete W.plans[name];const first=bhNames()[0];
if(W.init.bh===name)W.init.bh=first;for(const b of W.B)if(b.bh===name)setBh(b,first);switchBh(first);saveSoon()}
function renameBh(name){const nn=(prompt('Neuer Name',name)||'').trim();if(!nn||nn===name||P.behaviours[nn])return;const o={};
for(const k of bhNames())o[k===name?nn:k]=P.behaviours[k];P.behaviours=o;
for(const k in o)for(const n of o[k].nodes)if(n.type==='goto'&&n.params.to===name)n.params.to=nn;
for(const b of W.B)if(b.bh===name)b.bh=nn;if(W.init.bh===name)W.init.bh=nn;ED.cur=ED.cur===name?nn:ED.cur;recompileAll();renderTabs();renderGraph();refreshAll(true);saveSoon()}
function refreshAll(keepGraph){renderTabs();fillAllBh();fillBhSelect($('#selBh'),ED.cur);if($('#initBh'))fillBhSelect($('#initBh'),W.init.bh);onSelChange();refreshLib()}
/* ---- Bibliothek ---- */
const libGet=()=>{try{return JSON.parse(localStorage.getItem(LIB)||'{}')}catch(e){return{}}};
function refreshLib(){const s=$('#libSel'),L=libGet();s.innerHTML='<option value="">Bibliothek…</option>'+Object.keys(L).map(k=>`<option>${esc(k)}</option>`).join('')}
$('#libSave').onclick=()=>{const L=libGet();L[ED.cur]=JSON.parse(JSON.stringify(curG()));localStorage.setItem(LIB,JSON.stringify(L));refreshLib()};
$('#libSel').onchange=e=>{const k=e.target.value;if(!k)return;const L=libGet();if(L[k])addBh(k,JSON.parse(JSON.stringify(L[k])));e.target.value=''};
/* ================= LLM ================= */
const llmLog=$('#llmLog');let llmUndo=null;
const logL=(t,c)=>{const d=document.createElement('div');if(c)d.className=c;d.textContent=t;llmLog.appendChild(d);llmLog.scrollTop=1e9};
async function llmCall(messages){const r=await fetch('swarm_llm.php',{method:'POST',headers:{'Content-Type':'application/json','X-Gate':$('#llmGate').value},body:JSON.stringify({provider:$('#llmProv').value,model:$('#llmModel').value,messages})});
let j;try{j=await r.json()}catch(e){throw new Error('Keine JSON-Antwort (läuft swarm_llm.php?)')}if(!r.ok||j.error)throw new Error(j.error||'HTTP '+r.status);return j.content}
$('#llmModels').onclick=async()=>{try{const r=await fetch('swarm_llm.php?models='+$('#llmProv').value,{headers:{'X-Gate':$('#llmGate').value}}),j=await r.json();if(j.error)throw new Error(j.error);
let dl=$('#llmDl');if(!dl){dl=document.createElement('datalist');dl.id='llmDl';document.body.appendChild(dl);$('#llmModel').setAttribute('list','llmDl')}dl.innerHTML=(j.models||[]).map(m=>`<option value="${esc(m)}">`).join('');logL('Modelle: '+(j.models||[]).join(', '),'u')}catch(e){logL(e.message,'e')}};
function catalog(){return Object.values(DEFS).map(d=>`${d.id} "${d.label}" [${d.cat}] in(${d.ins.map(x=>x.n+':'+x.t).join(',')||'-'}) out(${d.outs.map(x=>x.n+':'+x.t).join(',')||'-'}) params(${d.params.map(x=>x.n+(x.o?'∈'+x.o.join('|'):'')+'='+JSON.stringify(x.d)).join(', ')||'-'})${d.doc?' — '+d.doc:''}`).join('\n')}
function graphText(g){return g.nodes.map(n=>`${n.id}=${n.type} ${JSON.stringify(n.params)}`).join('\n')+'\n'+g.wires.map(w=>`${w.f}.${w.fo} -> ${w.t}.${w.ti}`).join('\n')}
function telemetry(){let sp=0,near=0,nb=0;const cnt={};for(const b of W.B){cnt[b.bh]=(cnt[b.bh]||0)+1;const i=b.i;sp+=Math.hypot(W.vx[i],W.vy[i])*100;if(Math.min(W.x[i]+.5,.5-W.x[i],W.y[i]+.5,.5-W.y[i])<.05)near++;nb+=W.query(i,.14,360).length}
const n=Math.max(1,W.n);return`N=${W.n}, Verhalten=${JSON.stringify(cnt)}, ø Tempo=${(sp/n).toFixed(1)} cm/s, an der Wand=${near}/${W.n}, ø Nachbarn(14cm)=${(nb/n).toFixed(1)}, schlafend=${W.B.filter(b=>b.sleep>0).length}, Antrieb=${W.sim.drive}, Bühne 100x100 cm, Boid-Ø 3 cm`}
const SYS_EDIT=()=>`Du bist Verhaltens-Editor eines Boid-Schwarm-Simulators. Verhalten sind Node-Graphen, die jeder Boid jeden Tick auswertet. Ein Verhalten ist ein benannter Graph; der Node "goto" wechselt das Verhalten.
Antworte AUSSCHLIESSLICH mit JSON: {"say":"1-2 Sätze Erklärung","ops":[...]}. Keine Markdown-Fences.
Ops:
{"op":"add","id":"a1","type":"seek","x":0,"y":0,"params":{"weight":1}} (id frei wählbar, x/y optional)
{"op":"wire","from":"a1.steer","to":"n7.f1"} (Output-Port → Input-Port; ein Input hat max. 1 Kabel)
{"op":"unwire","to":"n7.f1"}
{"op":"set","id":"n3","params":{"radius":20}}
{"op":"del","id":"n3"}
{"op":"behaviour","name":"neues_verhalten"} (neues leeres Verhalten anlegen und dort weiterarbeiten)
{"op":"defnode","def":{"id":"mynode","label":"My Node","cat":"user","ins":[{"n":"a","t":"n"}],"outs":[{"n":"out","t":"n"}],"params":[{"n":"k","t":"n","d":1}],"act":false,"code":"return i.a*p.k;"}}
Datentypen: n Zahl, v Vektor {x,y} in Metern, l Liste von Boid-Indizes, o Boid-Referenz (Index), b Bool/Impuls, c Farbe {r,g,b}, * beliebig.
Regeln: Nodes nutzen cm, Grad, Sekunden. Ein unverbundener Input nimmt den Wert des gleichnamigen Parameters. Actions (act) haben einen Eingang "do": unverbunden = jeden Tick, verbunden = nur bei Impuls (Typ b). Kräfte (Typ v, Länge ≤ weight) laufen in "move" (f1..f4); mehrere Kräfte vorher mit "math" add zusammenfassen. Events (onenter, every, onmsg, rise, chance…) liefern Impulse. Nutze zuerst vorhandene Nodes; "defnode" nur wenn nötig. Code eines defnode: JS-Funktionsrumpf mit (i,p,b,w,H,s): i=Inputs, p=Params, b=Boid(b.i Index, b.vars), w=Welt (w.x[],w.y[],w.vx[],w.vy[],w.h[],w.query(i,radiusMeter,fovDeg), w.rand()), H=Helfer(V2,vnorm,vlim,vmul,clamp,hsv…). Gib bei mehreren Outputs ein Objekt {outName:wert} zurück.
Node-Katalog:
${catalog()}`;
function applyOps(ops){const log=[];pushHist();const idMap={};let cnt=0;
for(const op of ops){try{const g=curG();
if(op.op==='add'){if(!DEFS[op.type])throw new Error('unbekannter Node '+op.type);let id=String(op.id||'');if(!/^[\w-]+$/.test(id)||g.nodes.some(n=>n.id===id))id=nextId(g);if(op.id)idMap[op.id]=id;
const bx=g.nodes.reduce((m,n)=>Math.max(m,n.x),0)+220,x=op.x??bx+Math.floor(cnt/5)*200,y=op.y??10+(cnt%5)*110;cnt++;
const known=new Set((DEFS[op.type].params||[]).map(p=>p.n)),params={...defParams(op.type)};for(const k in op.params||{})if(known.has(k))params[k]=op.params[k];
g.nodes.push({id,type:op.type,x:Math.round(x),y:Math.round(y),params,ex:[]});log.push('+ '+op.type+' '+id)}
else if(op.op==='wire'){const[fa,fo]=String(op.from).split('.'),[ta,ti]=String(op.to).split('.'),f=idMap[fa]||fa,t=idMap[ta]||ta,fn=g.nodes.find(n=>n.id===f),tn=g.nodes.find(n=>n.id===t);
if(!fn||!tn)throw new Error('Node fehlt');if(!DEFS[fn.type]?.outs.some(o=>o.n===fo))throw new Error(`Output ${fa}.${fo} existiert nicht`);if(!DEFS[tn.type]?.ins.some(o=>o.n===ti))throw new Error(`Input ${ta}.${ti} existiert nicht`);connect(f,fo,t,ti);log.push(`~ ${f}.${fo} → ${t}.${ti}`)}
else if(op.op==='unwire'){const[ta,ti]=String(op.to).split('.'),t=idMap[ta]||ta;g.wires=g.wires.filter(w=>!(w.t===t&&w.ti===ti));log.push('− Kabel '+op.to)}
else if(op.op==='set'){const id=idMap[op.id]||op.id,n=g.nodes.find(x=>x.id===id);if(!n)throw new Error('Node fehlt');const known=new Set((DEFS[n.type]?.params||[]).map(p=>p.n));for(const k in op.params||{})if(known.has(k))n.params[k]=op.params[k];log.push('= '+id)}
else if(op.op==='del'){const id=idMap[op.id]||op.id;g.nodes=g.nodes.filter(n=>n.id!==id);g.wires=g.wires.filter(w=>w.f!==id&&w.t!==id);log.push('− '+id)}
else if(op.op==='behaviour'){touch();const nm=String(op.name||'neu');if(!P.behaviours[nm]){P.behaviours[nm]={nodes:[],wires:[]};W.plans[nm]=compile(P.behaviours[nm])}ED.cur=nm;log.push('Verhalten '+nm)}
else if(op.op==='defnode'){const d=op.def;if(!d||!d.id||!d.code)throw new Error('def unvollständig');if(DEFS[d.id]&&!DEFS[d.id].user)throw new Error('id belegt');
registerUserNode(d);P.user=P.user.filter(u=>u.id!==d.id);P.user.push(d);log.push('Node '+d.id)}
else throw new Error('unbekannte Op')}catch(e){log.push('✗ '+JSON.stringify(op).slice(0,90)+' — '+e.message)}}
recompileAll();touch();renderTabs();curG().view=null;renderGraph();refreshAll();return log}
function extractJSON(t){t=t.replace(/```json|```/g,'');const a=t.indexOf('{'),b=t.lastIndexOf('}');if(a<0||b<a)throw new Error('kein JSON in der Antwort');return JSON.parse(t.slice(a,b+1))}
$('#llmUndo').onclick=()=>{if(!llmUndo)return;P.behaviours=JSON.parse(llmUndo.b);ED.cur=P.behaviours[llmUndo.cur]?llmUndo.cur:bhNames()[0];recompileAll();renderTabs();renderGraph();refreshAll();llmUndo=null;logL('↶ Änderung zurückgenommen','u')};
$('#llmSend').onclick=async()=>{const text=$('#llmIn').value.trim();if(!text)return;const mode=$('#llmMode').value;logL('› '+text,'u');$('#llmState').textContent='denkt…';
try{let msgs;
if(mode==='edit')msgs=[{role:'system',content:SYS_EDIT()},{role:'user',content:`Aktuelles Verhalten "${ED.cur}" (alle Verhalten: ${bhNames().join(', ')}):\n${graphText(curG())}\n\nAuftrag: ${text}`}];
else msgs=[{role:'system',content:'Du erklärst und debuggst das Verhalten eines Boid-Schwarms (Node-Graphen). Antworte knapp auf Deutsch, nenne konkrete Nodes/Parameter, die man ändern sollte.\nNode-Katalog:\n'+catalog()},{role:'user',content:`Verhalten "${ED.cur}":\n${graphText(curG())}\n\nTelemetrie: ${telemetry()}\n\nFrage: ${text}`}];
const out=await llmCall(msgs);
if(mode==='edit'){const j=extractJSON(out);llmUndo={b:JSON.stringify(P.behaviours),cur:ED.cur};if(j.say)logL(j.say);const l=applyOps(j.ops||[]);logL(l.join('\n'),l.some(x=>x.startsWith('✗'))?'e':'u')}else logL(out);
}catch(e){logL(e.message,'e')}$('#llmState').textContent='';$('#llmIn').value=''};
/* ================= Start ================= */
loadInto(loadProject());
$('#selSpeed').value='1';
{let last=performance.now(),acc=0,fpsN=0,fpsT=last,tickMs=0,ticksN=0;
function frame(now){const dt=Math.min((now-last)/1000,0.1);last=now;
let ran=0;
if(W.running){acc+=dt*W.speed;let k=0;if(acc>=DT){D.n=0;D.nodeN=0}while(acc>=DT&&k<24){W.dbgTick=acc-DT<DT;const t0=performance.now();step();tickMs+=performance.now()-t0;ticksN++;acc-=DT;k++;ran++}if(k===24)acc=0}
else if(stepReq){D.n=0;D.nodeN=0;W.dbgTick=true;step();ran=1}
stepReq=false;W.dbgTick=false;
render(now);
fpsN++;if(now-fpsT>500){$('#fps').textContent=`${Math.round(fpsN*1000/(now-fpsT))} fps · tick ${(tickMs/Math.max(1,ticksN)).toFixed(2)} ms · t ${W.t.toFixed(0)}s`;fpsN=0;fpsT=now;tickMs=0;ticksN=0;
if($('#pBoid').classList.contains('on'))updateBoidPane();if(graphVisible())refreshStat()}
requestAnimationFrame(frame)}
requestAnimationFrame(frame)}
window.SWARM={W,P:()=>P,ED,DEFS,spawn,step,setSel,applyOps,compile,D};
</script>
</body>
</html>