Skip to content

Instantly share code, notes, and snippets.

@CypherpunkSamurai
Created August 19, 2026 08:49
Show Gist options
  • Select an option

  • Save CypherpunkSamurai/1ed8f3c20d46c24e6948903da5a6ee0c to your computer and use it in GitHub Desktop.

Select an option

Save CypherpunkSamurai/1ed8f3c20d46c24e6948903da5a6ee0c to your computer and use it in GitHub Desktop.
Unreal Engine node Editor
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Blueprint Graph — UE Node Editor</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&family=Roboto+Mono:wght@300;400;500&display=swap">
<script src="https://cdn.jsdelivr.net/npm/motion@latest/dist/motion.js"></script>
<style>
:root{
--bg:#1a1a1a; --panel:#262626; --panel-hover:#2e2e2e; --panel-light:#323232;
--border:#0a0a0a; --border-light:#3a3a3a;
--text:#d4d4d4; --text-dim:#888; --text-faint:#555;
--accent:#4a9eff; --accent-glow:rgba(74,158,255,.14);
--success:#7fbf7f; --error:#d94343;
}
*{margin:0;padding:0;box-sizing:border-box;user-select:none;-webkit-user-select:none}
html,body{height:100%}
body{background:var(--bg);color:var(--text);overflow:hidden;font:400 12px/1.4 'Roboto',system-ui,sans-serif;-webkit-font-smoothing:antialiased}
code,.mono{font-family:'Roboto Mono',ui-monospace,monospace;font-size:10px}
#app{display:flex;flex-direction:column;height:100vh}
#toolbar{height:36px;flex:none;display:flex;align-items:center;gap:10px;padding:0 10px;background:#2a2a2a;border-bottom:1px solid #0f0f0f;box-shadow:0 1px 3px rgba(0,0,0,.4);z-index:70}
.brand{display:flex;align-items:center;gap:7px;flex:none}
.brand .mark{width:18px;height:18px;flex:none}
.brand h1{font:500 11px 'Roboto';letter-spacing:1px;color:#b8b8b8;white-space:nowrap;text-transform:uppercase}
.brand h1 em{font-style:normal;color:#707070;font-weight:400}
.crumb{font:400 10px 'Roboto';color:#808080;white-space:nowrap}
.crumb b{color:#c0c0c0;font-weight:500}
.crumb i{font-style:normal;color:#505050;margin:0 5px}
.src-tag{font:400 9px 'Roboto Mono';color:#606060;border:1px solid #3a3a3a;padding:2px 6px;border-radius:2px;background:#2a2a2a;white-space:nowrap}
.tb-sep{width:1px;height:18px;background:#3a3a3a;flex:none;opacity:.4}
.tb-spacer{flex:1}
.tb-btn{display:inline-flex;align-items:center;gap:5px;height:22px;padding:0 8px;flex:none;border-radius:2px;border:1px solid #3a3a3a;cursor:pointer;background:#323232;color:#c0c0c0;font:400 10px 'Roboto';letter-spacing:.2px;transition:all .12s}
.tb-btn:hover{background:#3a3a3a;border-color:#4a4a4a;color:#e0e0e0}
.tb-btn:active{background:#2a2a2a}
.tb-btn svg{width:11px;height:11px;stroke:currentColor;fill:none;stroke-width:1.6;stroke-linecap:round;stroke-linejoin:round;flex:none}
.tb-btn.toggled{background:#3a3a3a;border-color:#4a4a4a;color:#e0e0e0}
.tb-btn.compile{border-color:#4a7a4a;color:#a0d0a0;background:#2d3d2d}
.tb-btn.compile:hover{border-color:#5a8a5a;color:#b0e0b0;background:#3a4a3a}
.tb-btn.compile .dot{width:6px;height:6px;border-radius:50%;background:var(--success);box-shadow:0 0 5px rgba(127,191,127,.6);flex:none}
.tb-btn.compile.busy .dot{background:transparent;border:1.4px solid rgba(127,191,127,.2);border-top-color:var(--success);animation:rot .7s linear infinite;box-shadow:none}
@keyframes rot{to{transform:rotate(360deg)}}
#main{flex:1;display:flex;min-height:0}
#left,#right{background:var(--panel);display:flex;flex-direction:column;overflow-y:auto;overflow-x:hidden;flex:none}
#left{width:222px;border-right:1px solid var(--border)}
#right{width:248px;border-left:1px solid var(--border)}
#app.hide-left #left{display:none}
#app.hide-right #right{display:none}
#left::-webkit-scrollbar,#right::-webkit-scrollbar{width:8px}
#left::-webkit-scrollbar-thumb,#right::-webkit-scrollbar-thumb{background:#3a3a3a;border-radius:4px}
.panel-head{font:500 9px 'Roboto';letter-spacing:1px;color:#a0a0a0;text-transform:uppercase;padding:9px 10px 7px;display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid var(--border);position:sticky;top:0;background:var(--panel);z-index:2}
.panel-head .mono{font:400 8px 'Roboto Mono';color:#606060;letter-spacing:0;text-transform:none}
.section{padding:8px 8px 2px}
.sec-title{font:500 8px 'Roboto';letter-spacing:.8px;color:#707070;text-transform:uppercase;margin:0 2px 5px;display:flex;align-items:center;gap:6px}
.sec-title::after{content:'';flex:1;height:1px;background:#3a3a3a}
.item{display:flex;align-items:center;gap:6px;padding:3px 6px;border-radius:2px;cursor:pointer;font:400 10px 'Roboto';color:#b0b0b0;transition:all .1s}
.item:hover{background:#3a3a3a;color:#e0e0e0}
.item .it-ic{width:11px;height:11px;flex:none;fill:#909090;color:#909090}
.item .it-ic svg{width:100%;height:100%;display:block}
.chip{font:400 8px 'Roboto Mono';padding:1px 4px;border-radius:2px;flex:none;border:1px solid color-mix(in srgb,var(--pc) 45%,transparent);color:color-mix(in srgb,var(--pc) 70%,white);background:color-mix(in srgb,var(--pc) 10%,transparent)}
.cat-row{display:flex;align-items:center;gap:7px;padding:3px 6px;border-radius:2px;cursor:pointer;transition:all .1s}
.cat-row:hover{background:#3a3a3a}
.mini-title{width:42px;height:15px;border-radius:3px;border:1px solid rgba(0,0,0,.7);flex:none;position:relative;overflow:hidden;background:linear-gradient(180deg,#2c2c2e,#232325);box-shadow:inset 0 1px 0 rgba(255,255,255,.05)}
.mini-title::before{content:'';position:absolute;inset:0;background:radial-gradient(130% 200% at 50% 15%,color-mix(in srgb,var(--cc) 66%,transparent),color-mix(in srgb,var(--cc) 30%,transparent) 55%,color-mix(in srgb,var(--cc) 8%,transparent) 100%)}
.mini-title::after{content:'';position:absolute;left:0;right:0;top:0;height:55%;background:linear-gradient(180deg,rgba(255,255,255,.34) 0%,rgba(255,255,255,.10) 45%,rgba(255,255,255,0) 100%)}
.cat-row .nm{font:400 10px 'Roboto';color:#b0b0b0}
.cat-row .ct{margin-left:auto;font:400 9px 'Roboto Mono';color:#606060}
.brush{display:flex;align-items:center;gap:7px;padding:2px 6px}
.brush .bar{width:42px;height:10px;border-radius:2px;border:1px solid rgba(0,0,0,.5);flex:none;background:#26262a}
.brush code{font:400 8px 'Roboto Mono';color:#b0b0b0;display:block}
.brush .ds{font:300 7.5px 'Roboto Mono';color:#606060;margin-top:1px}
.panel-foot{padding:10px 10px 13px;font:300 8px/1.6 'Roboto Mono';color:#606060}
#stage{position:relative;flex:1;min-width:0;background:var(--bg);overflow:hidden;cursor:default}
#stage.panning{cursor:grabbing}
#cv{position:absolute;inset:0;z-index:1}
#vignette{position:absolute;inset:0;pointer-events:none;z-index:2;background:radial-gradient(130% 100% at 50% 32%,transparent 55%,rgba(0,0,0,.38) 100%)}
#nodeLayer{position:absolute;inset:0;z-index:10;pointer-events:none;transform-origin:0 0}
/* ================= NODE — pixel-accurate to SGraphNode.cpp =================
Layer stack (bottom→top): Graph.Node.Body → Graph.Node.ColorSpill
→ Graph.Node.TitleGloss → Graph.Node.TitleHighlight */
.ue-node{position:absolute;pointer-events:auto}
.ue-node .body{
position:relative;min-width:182px;border-radius:5px;
background:linear-gradient(180deg,#2c2c2e 0%,#27272a 45%,#222225 100%);
border:1px solid rgba(0,0,0,.85);
box-shadow:inset 0 1px 0 rgba(255,255,255,.05),0 1px 2px rgba(0,0,0,.5),0 5px 14px rgba(0,0,0,.35);
transition:box-shadow .15s,filter .15s;
}
.ue-node:hover .body{box-shadow:inset 0 1px 0 rgba(255,255,255,.06),inset 0 0 0 1px rgba(255,255,255,.03),0 6px 16px rgba(0,0,0,.45)}
.ue-node.selected .body{box-shadow:inset 0 1px 0 rgba(255,255,255,.06),0 0 0 1.5px var(--accent),0 0 14px var(--accent-glow),0 6px 16px rgba(0,0,0,.45)}
.ue-node.dragging .body{box-shadow:inset 0 1px 0 rgba(255,255,255,.06),0 0 0 1px rgba(255,255,255,.05),0 14px 30px rgba(0,0,0,.6)}
.ue-node.catflash .body{box-shadow:inset 0 1px 0 rgba(255,255,255,.06),0 0 0 1.5px var(--nc),0 0 18px color-mix(in srgb,var(--nc) 45%,transparent),0 5px 14px rgba(0,0,0,.4)}
.ue-node.pulsing .body{animation:nodePulse .5s ease-out}
@keyframes nodePulse{
0%{box-shadow:inset 0 1px 0 rgba(255,255,255,.08),0 0 0 2px rgba(127,191,127,.85),0 0 20px rgba(127,191,127,.4),0 5px 14px rgba(0,0,0,.4)}
100%{box-shadow:inset 0 1px 0 rgba(255,255,255,.05),0 0 0 1.5px rgba(127,191,127,0),0 0 20px rgba(127,191,127,0),0 5px 14px rgba(0,0,0,.4)}
}
/* ---- title band ---- */
.ue-head{position:relative;overflow:hidden;display:flex;align-items:center;gap:6px;padding:6px 10px 6px 9px;min-height:27px;border-radius:4px 4px 0 0}
.ue-head .icon,.ue-head .ue-title{position:relative;z-index:2}
.head-spill,.head-gloss,.head-highlight{position:absolute;inset:0;pointer-events:none}
/* Graph.Node.ColorSpill — soft radial glow tinted by GetNodeTitleColor() */
.head-spill{
background:radial-gradient(130% 210% at 50% 16%,
color-mix(in srgb,var(--nc) 68%,transparent) 0%,
color-mix(in srgb,var(--nc) 42%,transparent) 42%,
color-mix(in srgb,var(--nc) 18%,transparent) 72%,
color-mix(in srgb,var(--nc) 5%,transparent) 100%);
transition:filter .15s;
}
.ue-node:hover .head-spill{filter:brightness(1.1)}
/* Graph.Node.TitleGloss — glass specular (bright top edge, falloff, faint bottom reflect) */
.head-gloss{
background:linear-gradient(180deg,
rgba(255,255,255,.36) 0%,
rgba(255,255,255,.17) 22%,
rgba(255,255,255,.06) 46%,
rgba(255,255,255,0) 60%,
rgba(255,255,255,.05) 86%,
rgba(0,0,0,.16) 100%);
opacity:.95;transition:opacity .15s;
}
.ue-node:hover .head-gloss{opacity:1}
/* Graph.Node.TitleHighlight — 1px top edge accent */
.head-highlight{background:linear-gradient(180deg,rgba(255,255,255,.30),rgba(255,255,255,0) 1.5px)}
.ue-head::after{content:'';position:absolute;left:0;right:0;bottom:0;height:1px;z-index:3;background:rgba(0,0,0,.45);box-shadow:0 1px 0 rgba(255,255,255,.03)}
.ue-head .icon{width:13px;height:13px;flex:none;fill:#f0f0f0;color:#f0f0f0;filter:drop-shadow(0 1px 1px rgba(0,0,0,.5))}
.ue-head .icon svg{width:100%;height:100%;display:block}
.ue-title{font:500 10.5px/1.15 'Roboto';color:#f2f2f2;letter-spacing:.1px;white-space:nowrap;text-shadow:0 1px 1px rgba(0,0,0,.6)}
/* ---- pin rows ---- */
.ue-rows{position:relative;padding:5px 0 6px;display:flex;flex-direction:column;gap:2px}
.ue-row{display:flex;min-height:17px}
.ue-cell{flex:1 1 0;display:flex;align-items:center;min-width:0}
.ue-cell.in{padding-left:7px}
.ue-cell.out{padding-right:7px;justify-content:flex-end}
/* ============ PINS — colors from UEdGraphSchema_K2::GetPinTypeColor ============ */
.ue-pin{display:flex;align-items:center;gap:5px;padding:1px 2px;cursor:crosshair;position:relative}
.plabel{font:400 9.5px 'Roboto';color:#b0b0b0;text-shadow:0 1px 1px rgba(0,0,0,.5);white-space:nowrap}
.pshape{overflow:visible;flex:none;transition:all .1s}
.pshape path,.pshape circle{fill:#1c1c1e;stroke:var(--pc,#c8c8c8);stroke-width:1.3;transition:fill .12s}
.pshape.on path,.pshape.on circle{fill:var(--pc,#c8c8c8)}
.ue-pin.connected .pshape path,.ue-pin.connected .pshape circle{fill:var(--pc,#c8c8c8)}
.ue-pin:hover .pshape,.ue-pin.active .pshape{filter:drop-shadow(0 0 4px var(--pc,#fff));transform:scale(1.15)}
.ue-pin.reject .pshape{filter:drop-shadow(0 0 5px var(--error))}
.pin-def{font:400 9px 'Roboto Mono';color:#989898;user-select:text;-webkit-user-select:text;background:#181818;border:1px solid rgba(0,0,0,.5);border-radius:2px;box-shadow:inset 0 1px 2px rgba(0,0,0,.4);padding:1px 4px;margin-left:2px;outline:none;min-width:26px;text-align:center;cursor:text;transition:all .12s}
.pin-def:hover{border-color:#4a4a4a}
.pin-def:focus{border-color:color-mix(in srgb,var(--nc) 55%,#555);color:#d0d0d0}
#zoomHud{position:absolute;right:10px;bottom:10px;z-index:40;display:flex;align-items:center;gap:1px;background:#2a2a2a;border:1px solid #3a3a3a;border-radius:3px;padding:2px;box-shadow:0 2px 8px rgba(0,0,0,.5)}
.zh-btn{width:22px;height:20px;display:grid;place-items:center;background:none;border:0;color:#808080;cursor:pointer;border-radius:2px;font:400 12px 'Roboto';transition:all .08s}
.zh-btn:hover{background:#3a3a3a;color:#c0c0c0}
#zoomPct{min-width:38px;text-align:center;font:400 9px 'Roboto Mono';color:#808080;cursor:pointer;padding:2px 0;border-radius:2px}
#zoomPct:hover{background:#3a3a3a;color:#c0c0c0}
#tooltip{position:absolute;z-index:55;pointer-events:none;max-width:240px;white-space:nowrap;background:rgba(20,20,20,.97);border:1px solid #3a3a3a;border-radius:2px;padding:4px 8px;font:400 9.5px/1.5 'Roboto Mono';color:#c0c0c0;box-shadow:0 4px 12px rgba(0,0,0,.6);opacity:0;transform:translateY(3px);transition:all .1s}
#tooltip.show{opacity:1;transform:none}
#tooltip b{color:#e0e0e0;font-weight:500}
#tooltip .sub{color:#808080}
#ctxMenu{position:absolute;z-index:60;width:200px;max-height:calc(100% - 24px);overflow-y:auto;background:#2a2a2a;border:1px solid #3a3a3a;border-radius:3px;padding:3px;box-shadow:0 8px 24px rgba(0,0,0,.7);opacity:0;transform:scale(.97) translateY(-4px);transform-origin:top left;pointer-events:none;transition:all .1s}
#ctxMenu.open{opacity:1;transform:none;pointer-events:auto}
#ctxMenu::-webkit-scrollbar{width:7px}
#ctxMenu::-webkit-scrollbar-thumb{background:#3a3a3a;border-radius:3px}
.ctx-head{font:500 8px 'Roboto';letter-spacing:1px;color:#a0a0a0;text-transform:uppercase;padding:4px 6px 5px;border-bottom:1px solid #3a3a3a;margin-bottom:2px;display:flex;justify-content:space-between}
.ctx-head span{font:400 8px 'Roboto Mono';letter-spacing:0;color:#606060;text-transform:none}
.ctx-cat{display:flex;align-items:center;gap:6px;font:500 8px 'Roboto';letter-spacing:.8px;color:color-mix(in srgb,var(--cc) 62%,white);padding:5px 6px 2px;text-transform:uppercase}
.ctx-cat i{width:13px;height:7px;border-radius:2px;border:1px solid rgba(0,0,0,.4);background:radial-gradient(130% 200% at 50% 15%,color-mix(in srgb,var(--cc) 66%,transparent),color-mix(in srgb,var(--cc) 12%,transparent) 100%),linear-gradient(180deg,rgba(255,255,255,.3),transparent 55%)}
.ctx-item{display:flex;align-items:center;gap:7px;width:100%;padding:4px 6px 4px 12px;border:0;border-radius:2px;background:none;color:#b0b0b0;font:400 10px 'Roboto';cursor:pointer;text-align:left;transition:all .08s}
.ctx-item:hover{background:#3a3a3a;color:#e0e0e0}
.ctx-item svg{width:10px;height:10px;flex:none;fill:#b0b0b0;color:#b0b0b0;opacity:.85}
.insp-pad{padding:10px 9px 7px}
.empty{text-align:center;color:#606060;font:400 10px/1.7 'Roboto';padding:10px 5px 15px}
.empty .cross{font-size:18px;color:#505050;display:block;margin-bottom:4px;animation:breathe 3s ease-in-out infinite}
@keyframes breathe{50%{opacity:.4;transform:scale(.94)}}
.empty .sub{font-size:9px;color:#505050}
.insp-cat{display:inline-flex;align-items:center;gap:5px;padding:2px 8px;border-radius:2px;font:500 8px 'Roboto';letter-spacing:1px;color:color-mix(in srgb,var(--nc) 65%,white);background:color-mix(in srgb,var(--nc) 12%,transparent);border:1px solid color-mix(in srgb,var(--nc) 35%,transparent);text-transform:uppercase}
.insp-cat i{width:7px;height:7px;border-radius:1px;background:var(--nc);box-shadow:0 0 5px var(--nc)}
.insp-title{font:400 13px/1.2 'Roboto';color:#d0d0d0;margin:8px 0 3px}
.insp-rgb{font:400 9px 'Roboto Mono';color:#808080;display:flex;align-items:center;gap:6px;margin-bottom:2px}
.swatch{width:9px;height:9px;border-radius:1px;background:var(--nc);border:1px solid rgba(0,0,0,.4);box-shadow:0 0 6px color-mix(in srgb,var(--nc) 45%,transparent);flex:none}
.insp-sec{font:500 8px 'Roboto';letter-spacing:.8px;color:#a0a0a0;margin:11px 2px 5px;display:flex;align-items:center;gap:6px;text-transform:uppercase}
.insp-sec::after{content:'';flex:1;height:1px;background:#3a3a3a}
.pin-row{display:flex;align-items:center;gap:6px;padding:3px 6px;border-radius:2px;font:400 9.5px 'Roboto';color:#b0b0b0;transition:all .08s}
.pin-row:hover{background:#3a3a3a}
.pin-row .nm{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.type-chip{font:400 8px 'Roboto Mono';padding:1px 4px;border-radius:1px;flex:none;border:1px solid color-mix(in srgb,var(--pc) 40%,transparent);color:color-mix(in srgb,var(--pc) 68%,white);background:color-mix(in srgb,var(--pc) 10%,transparent)}
.dir{font:400 7px 'Roboto Mono';color:#606060;border:1px solid #3a3a3a;padding:1px 3px;border-radius:1px;flex:none;text-transform:uppercase}
.insp-src{font:300 8px 'Roboto Mono';color:#505050;padding:10px 6px 8px;border-top:1px solid #3a3a3a;margin-top:10px}
#status{height:22px;flex:none;display:flex;align-items:center;gap:12px;padding:0 10px;background:#222;border-top:1px solid #0f0f0f;font:400 9px 'Roboto Mono';color:#707070;z-index:70}
#status .hint{color:#505050;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}
#status .mid{margin-left:auto;flex:none}
#status .right{display:flex;gap:10px;flex:none;align-items:center}
#status .ok{color:var(--success)}
#status .sel{color:var(--accent)}
#stCoords{min-width:130px;text-align:right}
@media(max-width:1180px){#right{display:none}#tglRight{display:none}}
@media(max-width:880px){#left{display:none}#tglLeft{display:none}.crumb,.src-tag{display:none}#status .hint{display:none}}
</style>
</head>
<body>
<div id="app">
<header id="toolbar">
<div class="brand">
<svg class="mark" viewBox="0 0 24 24" fill="none">
<path d="M5 17 12 7m0 0 7 10M12 7v0" stroke="#606060" stroke-width="1.4"/>
<path d="M5 17h14" stroke="#606060" stroke-width="1.4"/>
<circle cx="12" cy="6.5" r="2.5" fill="#FF0000"/>
<circle cx="5" cy="17.5" r="2.5" fill="#3195FF"/>
<circle cx="19" cy="17.5" r="2.5" fill="#66D959"/>
</svg>
<h1>Blueprint<em>·Graph</em></h1>
</div>
<div class="tb-sep"></div>
<div class="crumb">Blueprints <i>›</i> BP_PlayerCharacter <i>›</i> <b>EventGraph</b></div>
<span class="src-tag">SGraphNode.cpp</span>
<div class="tb-spacer"></div>
<button class="tb-btn" id="btnAdd"><svg viewBox="0 0 16 16"><path d="M8 3v10M3 8h10"/></svg>Add Node</button>
<button class="tb-btn" id="btnFit"><svg viewBox="0 0 16 16"><path d="M6 2H2v4M10 2h4v4M2 10v4h4M14 10v4h-4"/></svg>Fit</button>
<button class="tb-btn compile" id="btnCompile"><span class="dot"></span><span class="lbl">Compile</span></button>
<div class="tb-sep"></div>
<button class="tb-btn toggled" id="tglLeft" title="Toggle left panel"><svg viewBox="0 0 16 16"><rect x="2" y="3" width="12" height="10" rx="1"/><path d="M6 3v10"/></svg></button>
<button class="tb-btn toggled" id="tglRight" title="Toggle right panel"><svg viewBox="0 0 16 16"><rect x="2" y="3" width="12" height="10" rx="1"/><path d="M10 3v10"/></svg></button>
</header>
<div id="main">
<aside id="left">
<div class="panel-head">My Blueprint <span class="mono">BP_PlayerCharacter</span></div>
<div class="section"><div class="sec-title">Variables</div><div id="leftVars"></div></div>
<div class="section"><div class="sec-title">Functions</div><div id="leftFuncs"></div></div>
<div class="section">
<div class="sec-title">Node Title Colors</div>
<div class="cat-row" data-cat="event" style="--cc:#FF0000"><span class="mini-title"></span><span class="nm">Event</span><span class="ct">0</span></div>
<div class="cat-row" data-cat="function" style="--cc:#3195FF"><span class="mini-title"></span><span class="nm">Function Call</span><span class="ct">0</span></div>
<div class="cat-row" data-cat="pure" style="--cc:#66D959"><span class="mini-title"></span><span class="nm">Pure Function</span><span class="ct">0</span></div>
<div class="cat-row" data-cat="variable" style="--cc:#5BFF0F"><span class="mini-title"></span><span class="nm">Variable (by type)</span><span class="ct">0</span></div>
<div class="cat-row" data-cat="flow" style="--cc:#FFFFFF"><span class="mini-title"></span><span class="nm">Flow Control</span><span class="ct">0</span></div>
</div>
<div class="section">
<div class="sec-title">Title Layer Stack</div>
<div class="brush"><span class="bar" style="background:radial-gradient(130% 210% at 50% 16%,rgba(255,0,0,.6),rgba(255,0,0,.15) 70%,transparent),#26262a"></span><div><code>Graph.Node.ColorSpill</code><div class="ds">tint = GetNodeTitleColor()</div></div></div>
<div class="brush"><span class="bar" style="background:linear-gradient(180deg,rgba(255,255,255,.36),rgba(255,255,255,.05) 46%,transparent 60%,rgba(0,0,0,.16))"></span><div><code>Graph.Node.TitleGloss</code><div class="ds">glass specular overlay</div></div></div>
<div class="brush"><span class="bar" style="background:linear-gradient(180deg,rgba(255,255,255,.3),transparent 2px),#26262a"></span><div><code>Graph.Node.TitleHighlight</code><div class="ds">1px top edge</div></div></div>
<div class="brush"><span class="bar" style="background:linear-gradient(180deg,#2c2c2e,#222225)"></span><div><code>Graph.Node.Body</code><div class="ds">BOX_BRUSH 9-slice</div></div></div>
</div>
<div class="panel-foot">Source: GraphEditorSettings.cpp<br>SGraphNode.cpp · SlateEditorStyle.cpp</div>
</aside>
<div id="stage">
<canvas id="cv"></canvas>
<div id="vignette"></div>
<div id="nodeLayer"></div>
<div id="zoomHud">
<button class="zh-btn" id="zOut" title="Zoom out">−</button>
<span id="zoomPct" title="Reset to 100%">100%</span>
<button class="zh-btn" id="zIn" title="Zoom in">+</button>
<button class="zh-btn" id="zFit" title="Fit graph">⛶</button>
</div>
<div id="tooltip"></div>
<div id="ctxMenu"></div>
</div>
<aside id="right">
<div class="panel-head">Details <span class="mono">inspector</span></div>
<div id="insp"></div>
</aside>
</div>
<footer id="status">
<span class="hint">drag · RMB = add · wheel = zoom · alt-click pin = disconnect · ⌫ remove · ctrl+D dup · F fit</span>
<span class="mid" id="stCoords">X +0000 · Y +0000</span>
<span class="right">
<span id="selInfo"></span>
<span id="stNodes">0 nodes</span>
<span id="stWires">0 wires</span>
<span id="stCompile">not compiled</span>
</span>
</footer>
</div>
<script>
const { animate } = Motion;
/* Node title colors — exact values from UGraphEditorSettings (GraphEditorSettings.cpp) */
const CATS = {
event: {label:'Event', color:'#FF0000'}, // EventNodeTitleColor (1,0,0)
function:{label:'Function Call', color:'#3195FF'}, // FunctionCallNodeTitleColor (.19,.58,1)
pure: {label:'Pure Function', color:'#66D959'}, // PureFunctionCallNodeTitleColor (.4,.85,.35)
variable:{label:'Variable', color:'#5BFF0F'}, // colored by var type (float shown)
flow: {label:'Flow Control', color:'#FFFFFF'}, // ExecBranchNodeTitleColor (1,1,1)
};
/* Pin colors — exact values from UGraphEditorSettings (GetPinTypeColor) */
const PIN_COLORS = {
exec:'#FFFFFF', // ExecutionPinTypeColor
bool:'#4C0000', // BooleanPinTypeColor (.3,0,0)
float:'#5BFF0F', // FloatPinTypeColor (.36,1,.06)
int:'#03C46E', // IntPinTypeColor
vector:'#FF9704', // VectorPinTypeColor (1,.59,.02)
string:'#FF00A8', // StringPinTypeColor (1,0,.66)
object:'#0066E8', // ObjectPinTypeColor (0,.4,.91)
};
const PC_NAMES = {exec:'PC_Exec',bool:'PC_Boolean',float:'PC_Float',int:'PC_Int',vector:'PC_Struct·Vector',string:'PC_String',object:'PC_Object'};
const KIND_LABEL = {exec:'execution',bool:'boolean',float:'float',int:'integer',vector:'vector',string:'string',object:'object ref'};
const ICONS = {
event:'<svg viewBox="0 0 16 16"><path d="M9.2 1 3.2 9h3.2L5.2 15l7-8.6H8.8L9.2 1z"/></svg>',
function:'<svg viewBox="0 0 16 16"><text x="8" y="12" text-anchor="middle" font-size="12" font-style="italic" font-family="Georgia,serif">ƒ</text></svg>',
pure:'<svg viewBox="0 0 16 16"><text x="8" y="11.5" text-anchor="middle" font-size="9" font-style="italic" font-family="Georgia,serif">√ƒ</text></svg>',
variable:'<svg viewBox="0 0 16 16"><path d="M8 2 13 5v6L8 14 3 11V5z" fill="none" stroke="currentColor" stroke-width="1.3"/><circle cx="8" cy="8" r="1.7"/></svg>',
flow:'<svg viewBox="0 0 16 16"><path d="M2 8h4l3-4.2h5M6 8l3 4.2h5" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>',
};
const E = name => ({name, kind:'exec'});
const D = (name,kind,def) => ({name,kind,def});
/* Per-node true title color. `nc` overrides category color where UE differs
(Sequence=salmon, variables colored by their data type). */
const CATALOG = {
beginPlay: {cat:'event', title:'Event BeginPlay', ins:[], outs:[E('Exec')]},
tick: {cat:'event', title:'Event Tick', ins:[], outs:[E('Exec'), D('Delta Seconds','float')]},
anyDamage: {cat:'event', title:'Event AnyDamage', ins:[], outs:[E('Exec'), D('Damage','float')]},
printString: {cat:'function',title:'Print String', ins:[E('Exec'), D('In String','string','Hello')], outs:[E('Exec')]},
setActorLocation:{cat:'function',title:'Set Actor Location',ins:[E('Exec'), D('New Location','vector')], outs:[E('Exec')]},
delay: {cat:'function',title:'Delay', ins:[E('Exec'), D('Duration','float','2.0')], outs:[E('Exec')]},
getActorLocation:{cat:'pure', title:'Get Actor Location', ins:[], outs:[D('Return Value','vector')]},
less: {cat:'pure', title:'Float < Float', ins:[D('A','float'), D('B','float','30.0')], outs:[D('Return Value','bool')]},
makeVector: {cat:'pure', title:'Make Vector', ins:[D('X','float','0.0'),D('Y','float','0.0'),D('Z','float','0.0')], outs:[D('Return Value','vector')]},
health: {cat:'variable',title:'Health', nc:'#5BFF0F', ins:[], outs:[D('Health','float')]},
playerName: {cat:'variable',title:'Player Name', nc:'#FF00A8', ins:[], outs:[D('Player Name','string')]},
timeDilation: {cat:'variable',title:'Time Dilation', nc:'#5BFF0F', ins:[], outs:[D('Time Dilation','float')]},
branch: {cat:'flow', title:'Branch', ins:[E('Exec'), D('Condition','bool')], outs:[E('True'), E('False')]},
sequence: {cat:'flow', title:'Sequence', nc:'#CC6666', ins:[E('Exec')], outs:[E('Then 0'), E('Then 1')]},
forLoop: {cat:'flow', title:'For Loop', ins:[E('Exec'), D('First Index','int','0'), D('Last Index','int','9')], outs:[E('Loop Body'), D('Index','int'), E('Completed')]},
};
const nodeColor = def => def.nc || CATS[def.cat].color;
const LEFT_VARS = [['health','float'],['playerName','string'],['timeDilation','float']];
const LEFT_FUNCS = ['beginPlay','tick','anyDamage','printString','delay'];
const $ = id => document.getElementById(id);
const stage=$('stage'), cv=$('cv'), ctx=cv.getContext('2d'), nodeLayer=$('nodeLayer');
const tooltip=$('tooltip'), ctxMenu=$('ctxMenu'), insp=$('insp');
const stCoords=$('stCoords'), stNodes=$('stNodes'), stWires=$('stWires'), stCompile=$('stCompile'), selInfo=$('selInfo');
const btnCompile=$('btnCompile'), zoomPct=$('zoomPct');
const view={x:0,y:0,k:1};
let sw=0,sh=0,nid=1,wid=1,zTop=10;
const state={nodes:[],wires:[],pulses:[],selectedNode:null,selectedWire:null,hoverWire:null,hoverNode:null,drag:null,compiling:false};
function svgPin(dir,kind,on){
if(kind==='exec'){
const d = dir==='out' ? 'M3.5 2.5 11.5 7 3.5 11.5Z' : 'M10.5 2.5 2.5 7 10.5 11.5Z';
return `<svg class="pshape${on?' on':''}" viewBox="0 0 14 14" width="11" height="11"><path d="${d}"/></svg>`;
}
return `<svg class="pshape${on?' on':''}" viewBox="0 0 14 14" width="11" height="11"><circle cx="7" cy="7" r="4"/></svg>`;
}
const hexRgb = h => [parseInt(h.slice(1,3),16),parseInt(h.slice(3,5),16),parseInt(h.slice(5,7),16)].join(', ');
const nodeById = id => state.nodes.find(n=>n.id===id);
function pinPos(p){ const r=p.shapeEl.getBoundingClientRect(), s=stage.getBoundingClientRect(); return {x:r.left+r.width/2-s.left,y:r.top+r.height/2-s.top}; }
let rafPending=false;
function requestRender(){ if(!rafPending){rafPending=true;requestAnimationFrame(()=>{rafPending=false;render();});} }
function createNode(type,x,y,opt={}){
const def=CATALOG[type], nc=nodeColor(def);
const n={id:nid++,type,cat:def.cat,def,x,y,pins:[],el:null};
const rows=Math.max(def.ins.length,def.outs.length,1);
let rowsHtml='',defIdx=0;
for(let r=0;r<rows;r++){
const ip=def.ins[r],op=def.outs[r]; let inHtml='',outHtml='';
if(ip){
const col=PIN_COLORS[ip.kind];
const defVal=(opt.defs&&opt.defs[defIdx]!=null)?opt.defs[defIdx]:(ip.def||'');
inHtml=`<div class="ue-pin ${ip.kind==='exec'?'exec':'data'} in" data-pin="${n.pins.length}" style="--pc:${col}">
${svgPin('in',ip.kind,false)}<span class="plabel">${ip.name}</span>
${ip.kind!=='exec'&&defVal!==''?`<span class="pin-def" contenteditable="true" spellcheck="false">${defVal}</span>`:''}</div>`;
n.pins.push({node:n,dir:'in',kind:ip.kind,name:ip.name,def:defVal,el:null,shapeEl:null,wires:[]}); defIdx++;
}
if(op){
const col=PIN_COLORS[op.kind];
outHtml=`<div class="ue-pin ${op.kind==='exec'?'exec':'data'} out" data-pin="${n.pins.length}" style="--pc:${col}">
<span class="plabel">${op.name}</span>${svgPin('out',op.kind,false)}</div>`;
n.pins.push({node:n,dir:'out',kind:op.kind,name:op.name,def:'',el:null,shapeEl:null,wires:[]});
}
rowsHtml+=`<div class="ue-row"><div class="ue-cell in">${inHtml}</div><div class="ue-cell out">${outHtml}</div></div>`;
}
const el=document.createElement('div');
el.className='ue-node'; el.dataset.id=n.id;
el.style.cssText=`left:${x}px;top:${y}px;--nc:${nc};z-index:${zTop}`;
el.innerHTML=`<div class="body">
<div class="ue-head">
<div class="head-spill"></div><div class="head-gloss"></div><div class="head-highlight"></div>
<span class="icon">${ICONS[def.cat]}</span><span class="ue-title">${def.title}</span>
</div>
<div class="ue-rows">${rowsHtml}</div>
</div>`;
nodeLayer.appendChild(el); n.el=el;
n.pins.forEach((p,i)=>{ p.el=el.querySelector(`[data-pin="${i}"]`); p.shapeEl=p.el.querySelector('.pshape'); });
if(opt.spawn) animate(el,{scale:[0.9,1],opacity:[0,1]},{duration:0.35,ease:[0.34,1.2,0.64,1]});
state.nodes.push(n); return n;
}
function refreshPin(p){ p.el.classList.toggle('connected',p.wires.length>0); }
function addWire(from,to){
const w={id:wid++,from,to,kind:from.kind};
from.wires.push(w);to.wires.push(w);state.wires.push(w);
refreshPin(from);refreshPin(to);
animate([from.shapeEl,to.shapeEl],{scale:[1,1.25,1]},{duration:0.25,delay:[0,0.04]});
return w;
}
function removeWire(w){
state.wires=state.wires.filter(x=>x!==w);
w.from.wires=w.from.wires.filter(x=>x!==w);
w.to.wires=w.to.wires.filter(x=>x!==w);
refreshPin(w.from);refreshPin(w.to);
if(state.selectedWire===w){state.selectedWire=null;updateStats();}
}
function link(a,oi,b,ii){ addWire(a.pins.filter(p=>p.dir==='out')[oi],b.pins.filter(p=>p.dir==='in')[ii]); }
function removeNode(n){
[...state.wires].forEach(w=>{ if(w.from.node===n||w.to.node===n) removeWire(w); });
animate(n.el,{scale:[1,0.88],opacity:[1,0]},{duration:0.2});
setTimeout(()=>{ n.el.remove(); state.nodes=state.nodes.filter(x=>x!==n);
if(state.selectedNode===n){state.selectedNode=null;renderInspector();} updateStats(); requestRender(); },200);
}
function resize(){
const dpr=window.devicePixelRatio||1;
sw=stage.clientWidth;sh=stage.clientHeight;
cv.width=Math.max(1,sw*dpr);cv.height=Math.max(1,sh*dpr);
cv.style.width=sw+'px';cv.style.height=sh+'px';
ctx.setTransform(dpr,0,0,dpr,0,0); requestRender();
}
function applyView(){ nodeLayer.style.transform=`translate(${view.x}px,${view.y}px) scale(${view.k})`; zoomPct.textContent=Math.round(view.k*100)+'%'; }
function zoomAt(px,py,k2){ k2=Math.min(2.5,Math.max(0.35,k2)); view.x=px-(px-view.x)*(k2/view.k); view.y=py-(py-view.y)*(k2/view.k); view.k=k2; applyView(); requestRender(); }
function fitView(anim){
if(!state.nodes.length) return;
let x0=1e9,y0=1e9,x1=-1e9,y1=-1e9;
for(const n of state.nodes){x0=Math.min(x0,n.x);y0=Math.min(y0,n.y);x1=Math.max(x1,n.x+n.el.offsetWidth);y1=Math.max(y1,n.y+n.el.offsetHeight);}
const pad=55;
const k=Math.min(1.1,Math.max(0.3,Math.min((sw-pad*2)/(x1-x0),(sh-pad*2)/(y1-y0))));
const tx=(sw-(x1-x0)*k)/2-x0*k,ty=(sh-(y1-y0)*k)/2-y0*k;
if(!anim){view.x=tx;view.y=ty;view.k=k;applyView();requestRender();return;}
const s={...view},t0=performance.now(),dur=300;
(function step(){const p=Math.min(1,(performance.now()-t0)/dur),e=1-Math.pow(1-p,3);
view.x=s.x+(tx-s.x)*e;view.y=s.y+(ty-s.y)*e;view.k=s.k+(k-s.k)*e;applyView();requestRender();
if(p<1)requestAnimationFrame(step);})();
}
function drawGrid(){
ctx.fillStyle='#1a1a1a';ctx.fillRect(0,0,sw,sh);
const k=view.k;let minor=20;
while(minor*k<10)minor*=5;while(minor*k>58)minor/=5;
const gx0=Math.floor((-view.x/k)/minor),gx1=Math.ceil((sw-view.x)/k/minor);
const gy0=Math.floor((-view.y/k)/minor),gy1=Math.ceil((sh-view.y)/k/minor);
ctx.lineWidth=1;
for(let i=gx0;i<=gx1;i++){const sx=Math.round(i*minor*k+view.x)+.5;
ctx.strokeStyle=i===0?'rgba(255,255,255,.08)':(i%5===0?'rgba(255,255,255,.045)':'rgba(255,255,255,.022)');
ctx.beginPath();ctx.moveTo(sx,0);ctx.lineTo(sx,sh);ctx.stroke();}
for(let i=gy0;i<=gy1;i++){const sy=Math.round(i*minor*k+view.y)+.5;
ctx.strokeStyle=i===0?'rgba(255,255,255,.08)':(i%5===0?'rgba(255,255,255,.045)':'rgba(255,255,255,.022)');
ctx.beginPath();ctx.moveTo(0,sy);ctx.lineTo(sw,sy);ctx.stroke();}
}
const bezC=(a,b)=>Math.max(42,Math.abs(b.x-a.x)*.48);
function traceBez(a,b){const c=bezC(a,b);ctx.beginPath();ctx.moveTo(a.x,a.y);ctx.bezierCurveTo(a.x+c,a.y,b.x-c,b.y,b.x,b.y);}
function bezPt(a,b,t){const c=bezC(a,b),u=1-t;return{x:u*u*u*a.x+3*u*u*t*(a.x+c)+3*u*t*t*(b.x-c)+t*t*t*b.x,y:u*u*u*a.y+3*u*u*t*a.y+3*u*t*t*b.y+t*t*t*b.y};}
function strokeWire(w,mode){
const a=pinPos(w.from),b=pinPos(w.to);
const col=w.kind==='exec'?'#d8d8d8':PIN_COLORS[w.kind];
ctx.lineCap='round';
ctx.strokeStyle='rgba(0,0,0,.5)';ctx.lineWidth=mode?4.2:3.2;traceBez(a,b);ctx.stroke();
ctx.shadowColor=mode?col:'transparent';ctx.shadowBlur=mode?8:0;
ctx.strokeStyle=col;ctx.lineWidth=mode===2?2.6:mode===1?2.2:1.8;traceBez(a,b);ctx.stroke();
ctx.shadowBlur=0;
}
function hitWire(mx,my){
let best=null,bd=60;
for(const w of state.wires){const a=pinPos(w.from),b=pinPos(w.to);
for(let i=0;i<=22;i++){const q=bezPt(a,b,i/22),dx=q.x-mx,dy=q.y-my,d=dx*dx+dy*dy;if(d<bd){bd=d;best=w;}}}
return best;
}
function render(){
if(!sw||!sh)return;
const now=performance.now();
drawGrid();
for(const w of state.wires){let mode=0;
if(state.hoverNode&&(w.from.node===state.hoverNode||w.to.node===state.hoverNode))mode=1;
if(state.hoverWire===w)mode=1; if(state.selectedWire===w)mode=2; strokeWire(w,mode);}
state.pulses=state.pulses.filter(p=>now<p.t0+p.dur);
for(const p of state.pulses){const t=(now-p.t0)/p.dur;if(t<0||t>1)continue;
const a=pinPos(p.w.from),b=pinPos(p.w.to);
ctx.lineCap='round';ctx.strokeStyle='rgba(200,230,200,.28)';ctx.lineWidth=3.2;traceBez(a,b);ctx.stroke();
const pt=bezPt(a,b,t);
ctx.save();ctx.shadowColor='#a0d0a0';ctx.shadowBlur=14;ctx.fillStyle='#d0f0d0';
ctx.beginPath();ctx.arc(pt.x,pt.y,3.8,0,Math.PI*2);ctx.fill();ctx.restore();}
const d=state.drag;
if(d&&d.type==='wire'){const a=pinPos(d.pin),b={x:d.mx,y:d.my};
const col=d.pin.kind==='exec'?'#d8d8d8':PIN_COLORS[d.pin.kind];
ctx.lineCap='round';ctx.globalAlpha=.88;
ctx.strokeStyle='rgba(0,0,0,.5)';ctx.lineWidth=3;traceBez(a,b);ctx.stroke();
ctx.strokeStyle=col;ctx.lineWidth=1.8;traceBez(a,b);ctx.stroke();
ctx.globalAlpha=1;ctx.strokeStyle=col;ctx.lineWidth=1.4;
ctx.beginPath();ctx.arc(b.x,b.y,3.8,0,Math.PI*2);ctx.stroke();}
}
function pulseLoop(){requestRender();if(state.pulses.length)requestAnimationFrame(pulseLoop);}
function compile(){
if(state.compiling)return;
state.compiling=true;
btnCompile.classList.add('busy');btnCompile.querySelector('.lbl').textContent='Compiling';
stCompile.textContent='compiling…';stCompile.className='';
animate(btnCompile,{scale:[1,1.03,1]},{duration:0.35});
const depth=new Map(),q=[];
state.nodes.forEach(n=>{if(CATALOG[n.type].cat==='event'){depth.set(n,0);q.push(n);}});
while(q.length){const n=q.shift(),d=depth.get(n);
n.pins.forEach(p=>{if(p.dir==='out'&&p.kind==='exec')p.wires.forEach(w=>{
const t=w.to.node,nd=d+1;if(!depth.has(t)||depth.get(t)>nd){depth.set(t,nd);q.push(t);}});});}
const now=performance.now();let maxD=0;state.pulses=[];
state.wires.forEach(w=>{if(w.kind!=='exec')return;const d=depth.get(w.from.node);if(d==null)return;
maxD=Math.max(maxD,d);state.pulses.push({w,t0:now+220+d*200,dur:290});});
depth.forEach((d,n)=>{setTimeout(()=>{n.el.classList.add('pulsing');setTimeout(()=>n.el.classList.remove('pulsing'),520);},220+d*200);});
requestAnimationFrame(pulseLoop);
setTimeout(()=>{state.compiling=false;btnCompile.classList.remove('busy');btnCompile.querySelector('.lbl').textContent='Compile';
stCompile.textContent='✓ compiled '+new Date().toLocaleTimeString([],{hour12:false});stCompile.className='ok';
animate(btnCompile,{scale:[1,1.08,1]},{duration:0.25,delay:0.08});},220+maxD*200+380);
}
function selectNode(n){
if(state.selectedNode&&state.selectedNode.el)state.selectedNode.el.classList.remove('selected');
state.selectedNode=n;state.selectedWire=null;
if(n){n.el.classList.add('selected');animate(n.el,{scale:[1,1.015,1]},{duration:0.18});}
renderInspector();updateStats();requestRender();
}
function selectWire(w){
if(state.selectedNode){state.selectedNode.el.classList.remove('selected');state.selectedNode=null;}
state.selectedWire=w;renderInspector();updateStats();requestRender();
}
function defaultInspector(){
const rows=Object.entries(PIN_COLORS).map(([k,c])=>`
<div class="pin-row" style="--pc:${c}">${svgPin('out',k,true)}<span class="nm">${KIND_LABEL[k]}</span><span class="type-chip">${PC_NAMES[k]}</span></div>`).join('');
return `<div class="insp-pad">
<div class="empty"><span class="cross">◎</span>Nothing selected<br><span class="sub">click a node to inspect</span></div>
<div class="insp-sec">Pin Types</div>${rows}
<div class="insp-src">GetPinTypeColor · UEdGraphSchema_K2</div>
</div>`;
}
function nodeInspector(n){
const cat=CATS[n.cat],nc=nodeColor(n.def);
const pins=n.pins.map(p=>{const c=PIN_COLORS[p.kind];
return `<div class="pin-row" style="--pc:${c}">${svgPin(p.dir,p.kind,p.kind!=='exec')}<span class="nm">${p.name}</span><span class="type-chip">${PC_NAMES[p.kind]}</span><span class="dir">${p.dir}</span></div>`;}).join('');
return `<div class="insp-pad" style="--nc:${nc}">
<span class="insp-cat"><i></i>${cat.label}</span>
<div class="insp-title">${n.def.title}</div>
<div class="insp-rgb"><span class="swatch"></span>GetNodeTitleColor → ${nc}</div>
<div class="insp-sec">Title Layers</div>
<div class="brush"><span class="bar" style="background:radial-gradient(130% 210% at 50% 16%,color-mix(in srgb,var(--nc) 68%,transparent),color-mix(in srgb,var(--nc) 12%,transparent) 75%,transparent),#26262a"></span><div><code>ColorSpill</code><div class="ds">radial tint glow</div></div></div>
<div class="brush"><span class="bar" style="background:linear-gradient(180deg,rgba(255,255,255,.36),rgba(255,255,255,.05) 46%,transparent 60%,rgba(0,0,0,.16))"></span><div><code>TitleGloss</code><div class="ds">glass specular</div></div></div>
<div class="brush"><span class="bar" style="background:linear-gradient(180deg,rgba(255,255,255,.3),transparent 2px),#26262a"></span><div><code>TitleHighlight</code><div class="ds">top edge</div></div></div>
<div class="insp-sec">Pins · ${n.pins.length}</div>
${pins}
<div class="insp-src">SGraphNode.cpp · UpdateGraphNode()</div>
</div>`;
}
function renderInspector(){
insp.innerHTML=state.selectedNode?nodeInspector(state.selectedNode):defaultInspector();
if(state.selectedNode){const ip=insp.querySelector('.insp-pad');if(ip)animate(ip,{opacity:[0,1],y:[6,0]},{duration:0.22});}
}
function updateStats(){
stNodes.textContent=state.nodes.length+' nodes';stWires.textContent=state.wires.length+' wires';
document.querySelectorAll('.cat-row').forEach(r=>{const c=r.dataset.cat;r.querySelector('.ct').textContent=state.nodes.filter(n=>n.cat===c).length;});
selInfo.textContent=state.selectedWire?'wire · '+PC_NAMES[state.selectedWire.kind]:state.selectedNode?'node · '+state.selectedNode.def.title:'';
selInfo.className=state.selectedWire||state.selectedNode?'sel':'';
}
function showTipAt(mx,my,html){
tooltip.innerHTML=html;const w=tooltip.offsetWidth||150;
tooltip.style.left=Math.min(mx+14,sw-w-8)+'px';tooltip.style.top=Math.max(6,my-12)+'px';
tooltip.classList.add('show');animate(tooltip,{opacity:[0,1],y:[3,0]},{duration:0.1});
}
function showPinTip(pinEl,mx,my){
const n=nodeById(+pinEl.closest('.ue-node').dataset.id);const p=n.pins[+pinEl.dataset.pin];
showTipAt(mx,my,`<b>${p.name}</b><br><span class="sub">${KIND_LABEL[p.kind]} · ${PC_NAMES[p.kind]} · ${p.dir==='in'?'input':'output'}</span>`);
}
const hideTip=()=>{tooltip.classList.remove('show');animate(tooltip,{opacity:0,y:3},{duration:0.08});};
let ctxGraph={x:0,y:0};
function buildCtx(){
const order=['event','function','pure','variable','flow'];
let html=`<div class="ctx-head">Add Node <span>EventGraph</span></div>`;
for(const c of order){
html+=`<div class="ctx-cat" style="--cc:${CATS[c].color}"><i></i>${CATS[c].label}</div>`;
for(const [key,def] of Object.entries(CATALOG)){
if(def.cat!==c)continue;
html+=`<button class="ctx-item" data-type="${key}" style="--cc:${nodeColor(def)}">${ICONS[c]}${def.title}</button>`;}}
ctxMenu.innerHTML=html;
}
function openCtx(clientX,clientY){
const r=stage.getBoundingClientRect();
ctxGraph={x:(clientX-r.left-view.x)/view.k,y:(clientY-r.top-view.y)/view.k};
let lx=clientX-r.left,ly=clientY-r.top;
ctxMenu.classList.add('open');
const mw=ctxMenu.offsetWidth,mh=ctxMenu.offsetHeight;
lx=Math.min(lx,sw-mw-6);ly=Math.min(ly,sh-mh-6);
ctxMenu.style.left=Math.max(3,lx)+'px';ctxMenu.style.top=Math.max(3,ly)+'px';
animate(ctxMenu,{opacity:[0,1],scale:[0.97,1],y:[-4,0]},{duration:0.12,ease:[0.2,0.75,0.2,1]});
animate('.ctx-item',{opacity:[0,1],x:[-6,0]},{duration:0.1,delay:0.015});
}
const closeCtx=()=>{animate(ctxMenu,{opacity:0,scale:0.97},{duration:0.08});setTimeout(()=>ctxMenu.classList.remove('open'),80);};
ctxMenu.addEventListener('click',e=>{
const item=e.target.closest('.ctx-item');if(!item)return;
const n=createNode(item.dataset.type,ctxGraph.x-88,ctxGraph.y-20,{spawn:true});
closeCtx();selectNode(n);updateStats();requestRender();
});
nodeLayer.addEventListener('mousedown',e=>{
const nodeEl=e.target.closest('.ue-node');if(!nodeEl)return;
e.stopPropagation();
const n=nodeById(+nodeEl.dataset.id);
if(e.target.closest('.pin-def'))return;
selectNode(n);
const pinEl=e.target.closest('.ue-pin');
if(pinEl&&e.button===0){startWireDrag(n,pinEl,e);return;}
if(e.button!==0)return;
n.el.style.zIndex=++zTop;
state.drag={type:'node',node:n,sx:e.clientX,sy:e.clientY,ox:n.x,oy:n.y,moved:false};
n.el.classList.add('dragging');animate(n.el,{scale:0.985},{duration:0.08});hideTip();
});
function startWireDrag(n,pinEl,e){
e.preventDefault();
const pin=n.pins[+pinEl.dataset.pin];
if(e.altKey){[...pin.wires].forEach(removeWire);updateStats();requestRender();return;}
let startPin=pin;
if(pin.dir==='in'&&pin.wires.length){const w=pin.wires[0];removeWire(w);startPin=w.from;updateStats();}
const r=stage.getBoundingClientRect();
state.drag={type:'wire',pin:startPin,mx:e.clientX-r.left,my:e.clientY-r.top};
startPin.el.classList.add('active');animate(startPin.shapeEl,{scale:[1,1.2]},{duration:0.12});hideTip();
}
stage.addEventListener('mousedown',e=>{
if(e.target.closest('#zoomHud')||e.target.closest('#ctxMenu'))return;
closeCtx();
if(e.button===1)e.preventDefault();
const r=stage.getBoundingClientRect();const mx=e.clientX-r.left,my=e.clientY-r.top;
if(e.button===0){const w=hitWire(mx,my);if(w){selectWire(w);return;}}
if(e.button===0||e.button===1){state.drag={type:'pan',sx:e.clientX,sy:e.clientY,ox:view.x,oy:view.y,moved:false};stage.classList.add('panning');}
});
nodeLayer.addEventListener('mouseover',e=>{
const nodeEl=e.target.closest('.ue-node');
state.hoverNode=nodeEl?nodeById(+nodeEl.dataset.id):null;
const pinEl=e.target.closest('.ue-pin');
if(pinEl&&!state.drag){const r=stage.getBoundingClientRect();showPinTip(pinEl,e.clientX-r.left,e.clientY-r.top);
const pin=nodeById(+pinEl.closest('.ue-node').dataset.id).pins[+pinEl.dataset.pin];
animate(pin.shapeEl,{scale:1.15},{duration:0.12});}else hideTip();
requestRender();
});
nodeLayer.addEventListener('mouseout',e=>{
if(e.relatedTarget&&nodeLayer.contains(e.relatedTarget)){if(!e.relatedTarget.closest('.ue-pin'))hideTip();return;}
state.hoverNode=null;hideTip();
state.nodes.forEach(n=>n.pins.forEach(p=>{if(p.shapeEl&&!p.el.matches(':hover'))animate(p.shapeEl,{scale:1},{duration:0.12});}));
requestRender();
});
window.addEventListener('mousemove',e=>{
const r=stage.getBoundingClientRect();const mx=e.clientX-r.left,my=e.clientY-r.top;
const gx=(mx-view.x)/view.k,gy=(my-view.y)/view.k;
const fmt=v=>(v<0?'−':'+')+String(Math.abs(Math.round(v))).padStart(4,'0');
if(mx>=0&&my>=0&&mx<=sw&&my<=sh)stCoords.textContent=`X ${fmt(gx)} · Y ${fmt(gy)}`;
const d=state.drag;
if(!d){
if(e.target===cv){const w=hitWire(mx,my);
if(w!==state.hoverWire){state.hoverWire=w;requestRender();}
cv.style.cursor=w?'pointer':'';
if(w)showTipAt(mx,my,`<b>${w.kind==='exec'?'Exec':'Data'} wire</b><br><span class="sub">${PC_NAMES[w.kind]} · click select · ⌫ remove</span>`);else hideTip();
}else if(state.hoverWire){state.hoverWire=null;requestRender();}
return;}
hideTip();
if(d.type==='pan'){const dx=e.clientX-d.sx,dy=e.clientY-d.sy;if(Math.abs(dx)+Math.abs(dy)>3)d.moved=true;
view.x=d.ox+dx;view.y=d.oy+dy;applyView();requestRender();}
else if(d.type==='node'){const dx=(e.clientX-d.sx)/view.k,dy=(e.clientY-d.sy)/view.k;if(Math.abs(dx)+Math.abs(dy)>1.5/view.k)d.moved=true;
d.node.x=d.ox+dx;d.node.y=d.oy+dy;d.node.el.style.left=d.node.x+'px';d.node.el.style.top=d.node.y+'px';requestRender();}
else if(d.type==='wire'){d.mx=mx;d.my=my;requestRender();}
});
window.addEventListener('mouseup',e=>{
const d=state.drag;if(!d)return;
if(d.type==='pan'){if(!d.moved){selectNode(null);selectWire(null);}stage.classList.remove('panning');}
else if(d.type==='node'){d.node.el.classList.remove('dragging');
animate(d.node.el,{scale:[0.985,1]},{duration:0.18,ease:[0.34,1.4,0.64,1]});}
else if(d.type==='wire'){animate(d.pin.shapeEl,{scale:[1.2,1]},{duration:0.12});finishWire(d,e);}
state.drag=null;requestRender();
});
function finishWire(d,e){
d.pin.el.classList.remove('active');
const r=stage.getBoundingClientRect();const mx=e.clientX-r.left,my=e.clientY-r.top;
let best=null,bd=15*15;
for(const n of state.nodes)for(const p of n.pins){
if(p===d.pin||p.node===d.pin.node||p.dir===d.pin.dir||p.kind!==d.pin.kind)continue;
const q=pinPos(p),dx=q.x-mx,dy=q.y-my,dd=dx*dx+dy*dy;if(dd<bd){bd=dd;best=p;}}
if(best){const out=d.pin.dir==='out'?d.pin:best,inn=d.pin.dir==='out'?best:d.pin;
[...inn.wires].forEach(removeWire);addWire(out,inn);}
else{const el=document.elementFromPoint(e.clientX,e.clientY);
const pe=el&&el.closest?el.closest('.ue-pin'):null;
if(pe){pe.classList.add('reject');animate(pe,{x:[0,-3,3,-3,3,0]},{duration:0.25});setTimeout(()=>pe.classList.remove('reject'),320);}}
updateStats();
}
stage.addEventListener('wheel',e=>{e.preventDefault();const r=stage.getBoundingClientRect();zoomAt(e.clientX-r.left,e.clientY-r.top,view.k*Math.exp(-e.deltaY*0.0012));},{passive:false});
stage.addEventListener('contextmenu',e=>{e.preventDefault();if(e.target.closest('#zoomHud'))return;openCtx(e.clientX,e.clientY);});
stage.addEventListener('dblclick',e=>{if(e.target===cv)openCtx(e.clientX,e.clientY);});
stage.addEventListener('mouseleave',()=>{hideTip();if(!state.drag){state.hoverWire=null;state.hoverNode=null;requestRender();}});
window.addEventListener('mousedown',e=>{if(!e.target.closest('#ctxMenu'))closeCtx();},true);
window.addEventListener('keydown',e=>{
const editing=e.target.isContentEditable||/INPUT|TEXTAREA/.test(e.target.tagName);
if(e.key==='Escape'){closeCtx();
if(state.drag&&state.drag.type==='wire'){animate(state.drag.pin.shapeEl,{scale:1},{duration:0.12});
state.drag.pin.el.classList.remove('active');state.drag=null;requestRender();}
else{selectNode(null);selectWire(null);}return;}
if(editing)return;
if(e.key==='Delete'||e.key==='Backspace'){
if(state.selectedNode)removeNode(state.selectedNode);
else if(state.selectedWire){removeWire(state.selectedWire);updateStats();requestRender();}}
else if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='d'){e.preventDefault();if(state.selectedNode)duplicateNode(state.selectedNode);}
else if(e.key.toLowerCase()==='f'){fitView(true);}
});
function duplicateNode(n){
n.pins.forEach(p=>{if(p.el){const d=p.el.querySelector('.pin-def');if(d)p.def=d.textContent.trim();}});
const defs=n.pins.filter(p=>p.dir==='in'&&p.kind!=='exec').map(p=>p.def||null);
const c=createNode(n.type,n.x+32,n.y+32,{spawn:true,defs});
selectNode(c);updateStats();requestRender();
}
document.querySelectorAll('.tb-btn,.zh-btn').forEach(btn=>{
btn.addEventListener('mouseenter',()=>animate(btn,{scale:1.03},{duration:0.1}));
btn.addEventListener('mouseleave',()=>animate(btn,{scale:1},{duration:0.1}));
btn.addEventListener('mousedown',()=>animate(btn,{scale:0.97},{duration:0.06}));
btn.addEventListener('mouseup',()=>animate(btn,{scale:1.03},{duration:0.06}));
});
document.querySelectorAll('.item,.cat-row').forEach(item=>{
item.addEventListener('mouseenter',()=>animate(item,{x:2},{duration:0.1}));
item.addEventListener('mouseleave',()=>animate(item,{x:0},{duration:0.1}));
});
$('btnAdd').addEventListener('click',e=>{const r=e.currentTarget.getBoundingClientRect();openCtx(r.left,r.bottom+6);});
$('btnFit').addEventListener('click',()=>{animate($('btnFit'),{scale:[1,0.96,1]},{duration:0.18});fitView(true);});
btnCompile.addEventListener('click',compile);
$('zIn').addEventListener('click',()=>{animate($('zIn'),{scale:[1,1.15,1]},{duration:0.12});zoomAt(sw/2,sh/2,view.k*1.25);});
$('zOut').addEventListener('click',()=>{animate($('zOut'),{scale:[1,1.15,1]},{duration:0.12});zoomAt(sw/2,sh/2,view.k*0.8);});
zoomPct.addEventListener('click',()=>{animate(zoomPct,{scale:[1,0.92,1]},{duration:0.12});zoomAt(sw/2,sh/2,1);});
$('zFit').addEventListener('click',()=>{animate($('zFit'),{rotate:[0,90]},{duration:0.25});fitView(true);});
$('tglLeft').addEventListener('click',e=>{$('app').classList.toggle('hide-left');e.currentTarget.classList.toggle('toggled');
animate($('left'),{x:$('app').classList.contains('hide-left')?-222:0},{duration:0.25,ease:[0.2,0.75,0.2,1]});});
$('tglRight').addEventListener('click',e=>{$('app').classList.toggle('hide-right');e.currentTarget.classList.toggle('toggled');
animate($('right'),{x:$('app').classList.contains('hide-right')?248:0},{duration:0.25,ease:[0.2,0.75,0.2,1]});});
document.querySelectorAll('.cat-row').forEach(row=>{
row.addEventListener('click',()=>{const c=row.dataset.cat;
state.nodes.filter(n=>n.cat===c).forEach((n,i)=>{n.el.classList.add('catflash');
animate(n.el,{scale:[1,1.025,1],filter:['brightness(1)','brightness(1.15)','brightness(1)']},{duration:0.35,delay:i*0.04});
setTimeout(()=>n.el.classList.remove('catflash'),800);});});
});
$('leftVars').innerHTML=LEFT_VARS.map(([key,kind])=>`<div class="item" data-spawn="${key}"><span class="chip" style="--pc:${PIN_COLORS[kind]}">${kind}</span>${CATALOG[key].title}</div>`).join('');
$('leftFuncs').innerHTML=LEFT_FUNCS.map(key=>`<div class="item" data-spawn="${key}"><span class="it-ic">${ICONS[CATALOG[key].cat]}</span>${CATALOG[key].title}</div>`).join('');
document.querySelectorAll('[data-spawn]').forEach(b=>{
b.addEventListener('click',()=>{const gx=(sw/2-view.x)/view.k,gy=(sh/2-view.y)/view.k;
const n=createNode(b.dataset.spawn,gx-88+(Math.random()*38-19),gy-26+(Math.random()*28-14),{spawn:true});
selectNode(n);updateStats();requestRender();});
});
buildCtx();resize();new ResizeObserver(resize).observe(stage);
const N={};
N.begin =createNode('beginPlay',40,60);
N.print =createNode('printString',380,50,{defs:['Hello, Blueprint!']});
N.tick =createNode('tick',40,300);
N.branch=createNode('branch',640,280);
N.less =createNode('less',330,470,{defs:[null,'30.0']});
N.health=createNode('health',60,500);
N.getLoc=createNode('getActorLocation',640,540);
N.setLoc=createNode('setActorLocation',980,240);
N.delay =createNode('delay',980,470);
N.print2=createNode('printString',1300,460,{defs:['Health low!']});
link(N.begin,0,N.print,0);
link(N.tick,0,N.branch,0);
link(N.less,0,N.branch,1);
link(N.health,0,N.less,0);
link(N.branch,0,N.setLoc,0);
link(N.getLoc,0,N.setLoc,1);
link(N.branch,1,N.delay,0);
link(N.delay,0,N.print2,0);
fitView(false);renderInspector();updateStats();requestRender();
setTimeout(compile,900);
</script>
</body>
</html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Blueprint Graph — UE-style Node Editor</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&family=Roboto+Mono:wght@300;400;500&display=swap">
<script src="https://cdn.jsdelivr.net/npm/motion@latest/dist/motion.js"></script>
<style>
:root{
--bg:#1a1a1a;
--panel:#262626;
--panel-hover:#2e2e2e;
--panel-light:#323232;
--border:#0a0a0a;
--border-light:#3a3a3a;
--text:#d4d4d4;
--text-dim:#888888;
--text-faint:#555555;
--accent:#4a9eff;
--accent-dim:#3579c4;
--accent-glow:rgba(74,158,255,.12);
--success:#7fbf7f;
--error:#d94343;
--warning:#e6a23c;
}
*{margin:0;padding:0;box-sizing:border-box;user-select:none;-webkit-user-select:none}
html,body{height:100%}
body{background:var(--bg);color:var(--text);overflow:hidden;font:400 12px/1.4 'Roboto',system-ui,sans-serif;-webkit-font-smoothing:antialiased}
code,.mono{font-family:'Roboto Mono',ui-monospace,monospace;font-size:10px}
#app{display:flex;flex-direction:column;height:100vh}
/* ============ TOOLBAR ============ */
#toolbar{
height:36px;flex:none;display:flex;align-items:center;gap:10px;padding:0 10px;
background:#2a2a2a;
border-bottom:1px solid #0f0f0f;
box-shadow:0 1px 3px rgba(0,0,0,.4);
z-index:70;
}
.brand{display:flex;align-items:center;gap:7px;flex:none}
.brand .mark{width:18px;height:18px;flex:none}
.brand h1{font:500 11px 'Roboto';letter-spacing:1px;color:#b8b8b8;white-space:nowrap;text-transform:uppercase}
.brand h1 em{font-style:normal;color:#707070;font-weight:400}
.crumb{font:400 10px 'Roboto';color:#808080;white-space:nowrap}
.crumb b{color:#c0c0c0;font-weight:500}
.crumb i{font-style:normal;color:#505050;margin:0 5px}
.src-tag{font:400 9px 'Roboto Mono';color:#606060;border:1px solid #3a3a3a;padding:2px 6px;border-radius:2px;background:#2a2a2a;white-space:nowrap}
.tb-sep{width:1px;height:18px;background:#3a3a3a;flex:none;opacity:.4}
.tb-spacer{flex:1}
.tb-btn{
display:inline-flex;align-items:center;gap:5px;height:22px;padding:0 8px;flex:none;
border-radius:2px;border:1px solid #3a3a3a;cursor:pointer;
background:#323232;
color:#c0c0c0;font:400 10px 'Roboto';letter-spacing:.2px;
transition:all .12s;
}
.tb-btn:hover{background:#3a3a3a;border-color:#4a4a4a;color:#e0e0e0}
.tb-btn:active{background:#2a2a2a}
.tb-btn svg{width:11px;height:11px;stroke:currentColor;fill:none;stroke-width:1.6;stroke-linecap:round;stroke-linejoin:round;flex:none}
.tb-btn.toggled{background:#3a3a3a;border-color:#4a4a4a;color:#e0e0e0}
.tb-btn.compile{border-color:#4a7a4a;color:#a0d0a0;background:#2d3d2d}
.tb-btn.compile:hover{border-color:#5a8a5a;color:#b0e0b0;background:#3a4a3a}
.tb-btn.compile .dot{width:6px;height:6px;border-radius:50%;background:var(--success);box-shadow:0 0 5px rgba(127,191,127,.6);flex:none}
.tb-btn.compile.busy .dot{background:transparent;border:1.4px solid rgba(127,191,127,.2);border-top-color:var(--success);animation:rot .7s linear infinite;box-shadow:none}
@keyframes rot{to{transform:rotate(360deg)}}
/* ============ LAYOUT ============ */
#main{flex:1;display:flex;min-height:0}
#left,#right{background:var(--panel);display:flex;flex-direction:column;overflow-y:auto;overflow-x:hidden;flex:none}
#left{width:220px;border-right:1px solid var(--border)}
#right{width:245px;border-left:1px solid var(--border)}
#app.hide-left #left{display:none}
#app.hide-right #right{display:none}
#left::-webkit-scrollbar,#right::-webkit-scrollbar{width:8px}
#left::-webkit-scrollbar-thumb,#right::-webkit-scrollbar-thumb{background:#3a3a3a;border-radius:4px}
#left::-webkit-scrollbar-thumb:hover,#right::-webkit-scrollbar-thumb:hover{background:#4a4a4a}
.panel-head{
font:500 9px 'Roboto';letter-spacing:1px;color:#a0a0a0;text-transform:uppercase;
padding:9px 10px 7px;display:flex;justify-content:space-between;align-items:center;
border-bottom:1px solid var(--border);position:sticky;top:0;background:var(--panel);z-index:2;
}
.panel-head .mono{font:400 8px 'Roboto Mono';color:#606060;letter-spacing:0;text-transform:none}
.section{padding:8px 8px 2px}
.sec-title{
font:500 8px 'Roboto';letter-spacing:.8px;color:#707070;text-transform:uppercase;
margin:0 2px 5px;display:flex;align-items:center;gap:6px;
}
.sec-title::after{content:'';flex:1;height:1px;background:#3a3a3a}
.item{
display:flex;align-items:center;gap:6px;padding:3px 6px;border-radius:2px;cursor:pointer;
font:400 10px 'Roboto';color:#b0b0b0;
transition:all .1s;
}
.item:hover{background:#3a3a3a;color:#e0e0e0}
.item .it-ic{width:11px;height:11px;flex:none;fill:#808080;color:#808080}
.item .it-ic svg{width:100%;height:100%;display:block}
.chip{
font:400 8px 'Roboto Mono';padding:1px 4px;border-radius:2px;flex:none;
border:1px solid color-mix(in srgb,var(--pc) 40%,transparent);
color:color-mix(in srgb,var(--pc) 65%,white);
background:color-mix(in srgb,var(--pc) 8%,transparent);
}
.cat-row{display:flex;align-items:center;gap:7px;padding:3px 6px;border-radius:2px;cursor:pointer;transition:all .1s}
.cat-row:hover{background:#3a3a3a}
.mini-title{width:40px;height:14px;border-radius:2px;border:1px solid rgba(0,0,0,.6);flex:none;position:relative;overflow:hidden;background:linear-gradient(180deg,#2e2e32,#262629);box-shadow:inset 0 1px 0 rgba(255,255,255,.04)}
.mini-title::before{content:'';position:absolute;inset:0;background:radial-gradient(120% 160% at 18% 26%,color-mix(in srgb,var(--cc) 48%,transparent),color-mix(in srgb,var(--cc) 10%,transparent) 55%,transparent 85%)}
.mini-title::after{content:'';position:absolute;left:0;right:0;top:0;height:52%;background:linear-gradient(180deg,rgba(255,255,255,.35) 0%,rgba(255,255,255,.12) 32%,rgba(255,255,255,.03) 52%,rgba(0,0,0,.12) 100%)}
.cat-row .nm{font:400 10px 'Roboto';color:#b0b0b0}
.cat-row .ct{margin-left:auto;font:400 9px 'Roboto Mono';color:#606060}
.brush{display:flex;align-items:center;gap:7px;padding:2px 6px}
.brush .bar{width:40px;height:9px;border-radius:1px;border:1px solid rgba(0,0,0,.5);flex:none}
.brush code{font:400 8px 'Roboto Mono';color:#b0b0b0;display:block}
.brush .ds{font:300 7.5px 'Roboto Mono';color:#606060;margin-top:1px}
.panel-foot{padding:10px 10px 13px;font:300 8px/1.6 'Roboto Mono';color:#606060}
/* ============ STAGE ============ */
#stage{position:relative;flex:1;min-width:0;background:var(--bg);overflow:hidden;cursor:default}
#stage.panning{cursor:grabbing}
#cv{position:absolute;inset:0;z-index:1}
#vignette{position:absolute;inset:0;pointer-events:none;z-index:2;background:radial-gradient(130% 100% at 50% 32%,transparent 55%,rgba(0,0,0,.4) 100%)}
#nodeLayer{position:absolute;inset:0;z-index:10;pointer-events:none;transform-origin:0 0}
/* ============ NODE ============ */
.ue-node{position:absolute;pointer-events:auto}
.ue-node.spawn{animation:nodeSpawn .35s cubic-bezier(.2,.9,.3,1.2)}
@keyframes nodeSpawn{from{opacity:0;transform:scale(.9) translateY(6px)}to{opacity:1;transform:none}}
.ue-node .body{
position:relative;min-width:175px;border-radius:4px;
background:linear-gradient(180deg,#2e2e32 0%,#28282c 15%,#242428 55%,#212124 100%);
border:1px solid #0a0a0a;
box-shadow:inset 0 1px 0 rgba(255,255,255,.04),0 3px 8px rgba(0,0,0,.5);
transition:all .15s;
}
.ue-node:hover .body{box-shadow:inset 0 1px 0 rgba(255,255,255,.05),inset 0 0 0 1px rgba(255,255,255,.03),0 5px 14px rgba(0,0,0,.55)}
.ue-node.selected .body{box-shadow:inset 0 1px 0 rgba(255,255,255,.05),0 0 0 1.5px var(--accent),0 0 12px var(--accent-glow),0 5px 14px rgba(0,0,0,.55)}
.ue-node.dragging .body{box-shadow:inset 0 1px 0 rgba(255,255,255,.05),0 0 0 1px rgba(255,255,255,.04),0 12px 28px rgba(0,0,0,.65)}
.ue-node.catflash .body{box-shadow:inset 0 1px 0 rgba(255,255,255,.05),0 0 0 1.5px var(--nc),0 0 16px color-mix(in srgb,var(--nc) 40%,transparent),0 4px 12px rgba(0,0,0,.5)}
.ue-node.pulsing .body{animation:nodePulse .5s ease-out}
@keyframes nodePulse{
0%{box-shadow:inset 0 1px 0 rgba(255,255,255,.08),0 0 0 2px rgba(127,191,127,.85),0 0 18px rgba(127,191,127,.4),0 4px 12px rgba(0,0,0,.5)}
100%{box-shadow:inset 0 1px 0 rgba(255,255,255,.04),0 0 0 1.5px rgba(127,191,127,0),0 0 18px rgba(127,191,127,0),0 4px 12px rgba(0,0,0,.5)}
}
/* ---- title band ---- */
.ue-head{position:relative;overflow:hidden;display:flex;align-items:center;gap:6px;padding:5px 10px 5px 8px;min-height:24px;border-radius:3px 3px 0 0}
.ue-head .icon,.ue-head .ue-title{position:relative;z-index:2}
.head-spill,.head-gloss,.head-highlight{position:absolute;inset:0;pointer-events:none}
.head-spill{
background:radial-gradient(125% 160% at 16% 28%,
color-mix(in srgb,var(--nc) 48%,transparent) 0%,
color-mix(in srgb,var(--nc) 25%,transparent) 30%,
color-mix(in srgb,var(--nc) 8%,transparent) 55%,
color-mix(in srgb,var(--nc) 0%,transparent) 82%);
transition:filter .15s;
}
.ue-node:hover .head-spill{filter:brightness(1.08)}
.head-gloss{
background:linear-gradient(180deg,
rgba(255,255,255,.38) 0%,
rgba(255,255,255,.15) 16%,
rgba(255,255,255,.06) 34%,
rgba(255,255,255,.015) 54%,
rgba(255,255,255,0) 72%,
rgba(255,255,255,.03) 90%,
rgba(0,0,0,.15) 100%);
opacity:.92;transition:opacity .15s;
}
.ue-node:hover .head-gloss{opacity:.98}
.head-highlight{
background:
linear-gradient(180deg,rgba(255,255,255,.28),rgba(255,255,255,0) 2px),
radial-gradient(64px 18px at 12px -2px,rgba(255,255,255,.16),transparent 65%);
}
.ue-head::after{content:'';position:absolute;left:0;right:0;bottom:0;height:1px;z-index:3;background:rgba(0,0,0,.4);box-shadow:0 1px 0 rgba(255,255,255,.03)}
.ue-head .icon{width:12px;height:12px;flex:none;fill:#e8e8e8;color:#e8e8e8;filter:drop-shadow(0 1px 1px rgba(0,0,0,.5))}
.ue-head .icon svg{width:100%;height:100%;display:block}
.ue-title{font:400 10.5px/1.15 'Roboto';color:#e8e8e8;letter-spacing:0;white-space:nowrap;text-shadow:0 1px 1px rgba(0,0,0,.5)}
/* ---- pin rows ---- */
.ue-rows{position:relative;padding:5px 0 6px;display:flex;flex-direction:column;gap:2px}
.ue-row{display:flex;min-height:17px}
.ue-cell{flex:1 1 0;display:flex;align-items:center;min-width:0}
.ue-cell.in{padding-left:7px}
.ue-cell.out{padding-right:7px;justify-content:flex-end}
/* ============ PINS ============ */
.ue-pin{display:flex;align-items:center;gap:5px;padding:1px 2px;cursor:crosshair;position:relative}
.plabel{font:400 9.5px 'Roboto';color:#b0b0b0;text-shadow:0 1px 1px rgba(0,0,0,.5);white-space:nowrap}
.pshape{overflow:visible;flex:none;transition:all .1s}
.pshape path,.pshape circle{fill:#1a1a1a;stroke:var(--pc,#c8c8c8);stroke-width:1.3;transition:fill .12s}
.pshape.on path,.pshape.on circle{fill:var(--pc,#c8c8c8)}
.ue-pin.connected .pshape path,.ue-pin.connected .pshape circle{fill:var(--pc,#c8c8c8)}
.ue-pin:hover .pshape,.ue-pin.active .pshape{filter:drop-shadow(0 0 4px var(--pc,#fff));transform:scale(1.15)}
.ue-pin.reject .pshape{filter:drop-shadow(0 0 5px var(--error))}
.pin-def{font:400 9px 'Roboto Mono';color:#989898;user-select:text;-webkit-user-select:text;background:#181818;border:1px solid rgba(0,0,0,.5);border-radius:2px;box-shadow:inset 0 1px 2px rgba(0,0,0,.4);padding:1px 4px;margin-left:2px;outline:none;min-width:26px;text-align:center;cursor:text;transition:all .12s}
.pin-def:hover{border-color:#4a4a4a}
.pin-def:focus{border-color:color-mix(in srgb,var(--nc) 55%,#555);color:#d0d0d0;box-shadow:inset 0 1px 2px rgba(0,0,0,.4),0 0 5px color-mix(in srgb,var(--nc) 30%,transparent)}
/* ============ HUD / MENU / TIP ============ */
#zoomHud{position:absolute;right:10px;bottom:10px;z-index:40;display:flex;align-items:center;gap:1px;background:#2a2a2a;border:1px solid #3a3a3a;border-radius:3px;padding:2px;box-shadow:0 2px 8px rgba(0,0,0,.5)}
.zh-btn{width:22px;height:20px;display:grid;place-items:center;background:none;border:0;color:#808080;cursor:pointer;border-radius:2px;font:400 12px 'Roboto';transition:all .08s}
.zh-btn:hover{background:#3a3a3a;color:#c0c0c0}
#zoomPct{min-width:38px;text-align:center;font:400 9px 'Roboto Mono';color:#808080;cursor:pointer;padding:2px 0;border-radius:2px}
#zoomPct:hover{background:#3a3a3a;color:#c0c0c0}
#tooltip{position:absolute;z-index:55;pointer-events:none;max-width:240px;white-space:nowrap;background:rgba(20,20,20,.97);border:1px solid #3a3a3a;border-radius:2px;padding:4px 8px;font:400 9.5px/1.5 'Roboto Mono';color:#c0c0c0;box-shadow:0 4px 12px rgba(0,0,0,.6);opacity:0;transform:translateY(3px);transition:all .1s}
#tooltip.show{opacity:1;transform:none}
#tooltip b{color:#e0e0e0;font-weight:500}
#tooltip .sub{color:#808080}
#ctxMenu{position:absolute;z-index:60;width:200px;max-height:calc(100% - 24px);overflow-y:auto;background:#2a2a2a;border:1px solid #3a3a3a;border-radius:3px;padding:3px;box-shadow:0 8px 24px rgba(0,0,0,.7);opacity:0;transform:scale(.97) translateY(-4px);transform-origin:top left;pointer-events:none;transition:all .1s}
#ctxMenu.open{opacity:1;transform:none;pointer-events:auto}
#ctxMenu::-webkit-scrollbar{width:7px}
#ctxMenu::-webkit-scrollbar-thumb{background:#3a3a3a;border-radius:3px}
.ctx-head{font:500 8px 'Roboto';letter-spacing:1px;color:#a0a0a0;text-transform:uppercase;padding:4px 6px 5px;border-bottom:1px solid #3a3a3a;margin-bottom:2px;display:flex;justify-content:space-between}
.ctx-head span{font:400 8px 'Roboto Mono';letter-spacing:0;color:#606060;text-transform:none}
.ctx-cat{display:flex;align-items:center;gap:6px;font:500 8px 'Roboto';letter-spacing:.8px;color:color-mix(in srgb,var(--cc) 58%,white);padding:5px 6px 2px;text-transform:uppercase}
.ctx-cat i{width:13px;height:7px;border-radius:1px;border:1px solid rgba(0,0,0,.4);background:radial-gradient(115% 145% at 22% 22%,color-mix(in srgb,var(--cc) 62%,transparent),transparent 82%),linear-gradient(180deg,rgba(255,255,255,.24),rgba(0,0,0,.12))}
.ctx-item{display:flex;align-items:center;gap:7px;width:100%;padding:4px 6px 4px 12px;border:0;border-radius:2px;background:none;color:#b0b0b0;font:400 10px 'Roboto';cursor:pointer;text-align:left;transition:all .08s}
.ctx-item:hover{background:#3a3a3a;color:#e0e0e0}
.ctx-item svg{width:10px;height:10px;flex:none;fill:#b0b0b0;color:#b0b0b0;opacity:.8}
/* ============ INSPECTOR ============ */
.insp-pad{padding:10px 9px 7px}
.empty{text-align:center;color:#606060;font:400 10px/1.7 'Roboto';padding:10px 5px 15px}
.empty .cross{font-size:18px;color:#505050;display:block;margin-bottom:4px;animation:breathe 3s ease-in-out infinite}
@keyframes breathe{50%{opacity:.4;transform:scale(.94)}}
.empty .sub{font-size:9px;color:#505050}
.insp-cat{display:inline-flex;align-items:center;gap:5px;padding:2px 8px;border-radius:2px;font:500 8px 'Roboto';letter-spacing:1px;color:color-mix(in srgb,var(--nc) 60%,white);background:color-mix(in srgb,var(--nc) 10%,transparent);border:1px solid color-mix(in srgb,var(--nc) 32%,transparent);text-transform:uppercase}
.insp-cat i{width:7px;height:7px;border-radius:1px;background:var(--nc);box-shadow:0 0 5px var(--nc)}
.insp-title{font:400 13px/1.2 'Roboto';color:#d0d0d0;margin:8px 0 3px}
.insp-rgb{font:400 9px 'Roboto Mono';color:#808080;display:flex;align-items:center;gap:6px;margin-bottom:2px}
.swatch{width:9px;height:9px;border-radius:1px;background:var(--nc);border:1px solid rgba(0,0,0,.4);box-shadow:0 0 6px color-mix(in srgb,var(--nc) 45%,transparent);flex:none}
.insp-sec{font:500 8px 'Roboto';letter-spacing:.8px;color:#a0a0a0;margin:11px 2px 5px;display:flex;align-items:center;gap:6px;text-transform:uppercase}
.insp-sec::after{content:'';flex:1;height:1px;background:#3a3a3a}
.pin-row{display:flex;align-items:center;gap:6px;padding:3px 6px;border-radius:2px;font:400 9.5px 'Roboto';color:#b0b0b0;transition:all .08s}
.pin-row:hover{background:#3a3a3a}
.pin-row .nm{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.type-chip{font:400 8px 'Roboto Mono';padding:1px 4px;border-radius:1px;flex:none;border:1px solid color-mix(in srgb,var(--pc) 38%,transparent);color:color-mix(in srgb,var(--pc) 62%,white);background:color-mix(in srgb,var(--pc) 8%,transparent)}
.dir{font:400 7px 'Roboto Mono';color:#606060;border:1px solid #3a3a3a;padding:1px 3px;border-radius:1px;flex:none;text-transform:uppercase}
.insp-src{font:300 8px 'Roboto Mono';color:#505050;padding:10px 6px 8px;border-top:1px solid #3a3a3a;margin-top:10px}
/* ============ STATUS BAR ============ */
#status{
height:22px;flex:none;display:flex;align-items:center;gap:12px;padding:0 10px;
background:#222222;border-top:1px solid #0f0f0f;
font:400 9px 'Roboto Mono';color:#707070;z-index:70;
}
#status .hint{color:#505050;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}
#status .mid{margin-left:auto;flex:none}
#status .right{display:flex;gap:10px;flex:none;align-items:center}
#status .ok{color:var(--success)}
#status .sel{color:var(--accent)}
#stCoords{min-width:130px;text-align:right}
@media(max-width:1180px){#right{display:none}#tglRight{display:none}}
@media(max-width:880px){#left{display:none}#tglLeft{display:none}.crumb,.src-tag{display:none}#status .hint{display:none}}
</style>
</head>
<body>
<div id="app">
<header id="toolbar">
<div class="brand">
<svg class="mark" viewBox="0 0 24 24" fill="none">
<path d="M5 17 12 7m0 0 7 10M12 7v0" stroke="#606060" stroke-width="1.4"/>
<path d="M5 17h14" stroke="#606060" stroke-width="1.4"/>
<circle cx="12" cy="6.5" r="2.5" fill="#E62828"/>
<circle cx="5" cy="17.5" r="2.5" fill="#4173E3"/>
<circle cx="19" cy="17.5" r="2.5" fill="#22B566"/>
</svg>
<h1>Blueprint<em>·Graph</em></h1>
</div>
<div class="tb-sep"></div>
<div class="crumb">Blueprints <i>›</i> BP_PlayerCharacter <i>›</i> <b>EventGraph</b></div>
<span class="src-tag">SGraphNode.cpp</span>
<div class="tb-spacer"></div>
<button class="tb-btn" id="btnAdd"><svg viewBox="0 0 16 16"><path d="M8 3v10M3 8h10"/></svg>Add Node</button>
<button class="tb-btn" id="btnFit"><svg viewBox="0 0 16 16"><path d="M6 2H2v4M10 2h4v4M2 10v4h4M14 10v4h-4"/></svg>Fit</button>
<button class="tb-btn compile" id="btnCompile"><span class="dot"></span><span class="lbl">Compile</span></button>
<div class="tb-sep"></div>
<button class="tb-btn toggled" id="tglLeft" title="Toggle left panel"><svg viewBox="0 0 16 16"><rect x="2" y="3" width="12" height="10" rx="1"/><path d="M6 3v10"/></svg></button>
<button class="tb-btn toggled" id="tglRight" title="Toggle right panel"><svg viewBox="0 0 16 16"><rect x="2" y="3" width="12" height="10" rx="1"/><path d="M10 3v10"/></svg></button>
</header>
<div id="main">
<aside id="left">
<div class="panel-head">My Blueprint <span class="mono">BP_PlayerCharacter</span></div>
<div class="section"><div class="sec-title">Variables</div><div id="leftVars"></div></div>
<div class="section"><div class="sec-title">Functions</div><div id="leftFuncs"></div></div>
<div class="section">
<div class="sec-title">Categories</div>
<div class="cat-row" data-cat="event" style="--cc:#E62828"><span class="mini-title"></span><span class="nm">Event</span><span class="ct">0</span></div>
<div class="cat-row" data-cat="function" style="--cc:#4173E3"><span class="mini-title"></span><span class="nm">Function Call</span><span class="ct">0</span></div>
<div class="cat-row" data-cat="pure" style="--cc:#22B566"><span class="mini-title"></span><span class="nm">Pure Function</span><span class="ct">0</span></div>
<div class="cat-row" data-cat="variable" style="--cc:#22A7BF"><span class="mini-title"></span><span class="nm">Variable</span><span class="ct">0</span></div>
<div class="cat-row" data-cat="flow" style="--cc:#8186A0"><span class="mini-title"></span><span class="nm">Flow Control</span><span class="ct">0</span></div>
</div>
<div class="section">
<div class="sec-title">Title Layers</div>
<div class="brush"><span class="bar" style="background:radial-gradient(115% 145% at 22% 28%,rgba(65,115,227,.58),transparent 78%),#242428"></span><div><code>ColorSpill</code><div class="ds">radial glow · category</div></div></div>
<div class="brush"><span class="bar" style="background:linear-gradient(180deg,rgba(255,255,255,.38),rgba(255,255,255,.04) 52%,rgba(0,0,0,.15))"></span><div><code>Gloss</code><div class="ds">specular · over spill</div></div></div>
<div class="brush"><span class="bar" style="background:linear-gradient(180deg,rgba(255,255,255,.28),transparent 2px),#242428"></span><div><code>Highlight</code><div class="ds">top edge</div></div></div>
<div class="brush"><span class="bar" style="background:linear-gradient(180deg,#2e2e32,#212124)"></span><div><code>Body</code><div class="ds">slate background</div></div></div>
</div>
<div class="panel-foot">UE5 Blueprint · SGraphNode.cpp</div>
</aside>
<div id="stage">
<canvas id="cv"></canvas>
<div id="vignette"></div>
<div id="nodeLayer"></div>
<div id="zoomHud">
<button class="zh-btn" id="zOut" title="Zoom out">−</button>
<span id="zoomPct" title="Reset to 100%">100%</span>
<button class="zh-btn" id="zIn" title="Zoom in">+</button>
<button class="zh-btn" id="zFit" title="Fit graph">⛶</button>
</div>
<div id="tooltip"></div>
<div id="ctxMenu"></div>
</div>
<aside id="right">
<div class="panel-head">Details <span class="mono">inspector</span></div>
<div id="insp"></div>
</aside>
</div>
<footer id="status">
<span class="hint">drag · RMB = add · wheel = zoom · alt-click pin = disconnect · ⌫ remove · ctrl+D dup · F fit</span>
<span class="mid" id="stCoords">X +0000 · Y +0000</span>
<span class="right">
<span id="selInfo"></span>
<span id="stNodes">0 nodes</span>
<span id="stWires">0 wires</span>
<span id="stCompile">not compiled</span>
</span>
</footer>
</div>
<script>
const { animate } = Motion;
const CATS = {
event: {label:'Event', color:'#E62828'},
function:{label:'Function Call', color:'#4173E3'},
pure: {label:'Pure Function', color:'#22B566'},
variable:{label:'Variable', color:'#22A7BF'},
flow: {label:'Flow Control', color:'#8186A0'},
};
const PIN_COLORS = {exec:'#c8c8c8', bool:'#d94343', float:'#9cb854', int:'#45b8d0', vector:'#d9b845', string:'#c958c0', object:'#5888d0'};
const PC_NAMES = {exec:'PC_Exec', bool:'PC_Boolean', float:'PC_Float', int:'PC_Int', vector:'PC_Struct·Vector', string:'PC_String', object:'PC_Object'};
const KIND_LABEL = {exec:'execution', bool:'boolean', float:'float', int:'integer', vector:'vector', string:'string', object:'object ref'};
const ICONS = {
event:'<svg viewBox="0 0 16 16"><path d="M9.2 1 3.2 9h3.2L5.2 15l7-8.6H8.8L9.2 1z"/></svg>',
function:'<svg viewBox="0 0 16 16"><text x="8" y="12" text-anchor="middle" font-size="12" font-style="italic" font-family="Georgia,serif">ƒ</text></svg>',
pure:'<svg viewBox="0 0 16 16"><text x="8" y="11.5" text-anchor="middle" font-size="9" font-style="italic" font-family="Georgia,serif">√ƒ</text></svg>',
variable:'<svg viewBox="0 0 16 16"><path d="M8 2 13 5v6L8 14 3 11V5z" fill="none" stroke="currentColor" stroke-width="1.3"/><circle cx="8" cy="8" r="1.7"/></svg>',
flow:'<svg viewBox="0 0 16 16"><path d="M2 8h4l3-4.2h5M6 8l3 4.2h5" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>',
};
const E = name => ({name, kind:'exec'});
const D = (name,kind,def) => ({name,kind,def});
const CATALOG = {
beginPlay: {cat:'event', title:'Event BeginPlay', ins:[], outs:[E('Exec')]},
tick: {cat:'event', title:'Event Tick', ins:[], outs:[E('Exec'), D('Delta Seconds','float')]},
anyDamage: {cat:'event', title:'Event AnyDamage', ins:[], outs:[E('Exec'), D('Damage','float')]},
printString: {cat:'function',title:'Print String', ins:[E('Exec'), D('In String','string','Hello')], outs:[E('Exec')]},
setActorLocation:{cat:'function',title:'Set Actor Location',ins:[E('Exec'), D('New Location','vector')], outs:[E('Exec')]},
delay: {cat:'function',title:'Delay', ins:[E('Exec'), D('Duration','float','2.0')], outs:[E('Exec')]},
getActorLocation:{cat:'pure', title:'Get Actor Location', ins:[], outs:[D('Return Value','vector')]},
less: {cat:'pure', title:'Float < Float', ins:[D('A','float'), D('B','float','30.0')], outs:[D('Return Value','bool')]},
makeVector: {cat:'pure', title:'Make Vector', ins:[D('X','float','0.0'),D('Y','float','0.0'),D('Z','float','0.0')], outs:[D('Return Value','vector')]},
health: {cat:'variable',title:'Health', ins:[], outs:[D('Health','float')]},
playerName: {cat:'variable',title:'Player Name', ins:[], outs:[D('Player Name','string')]},
timeDilation: {cat:'variable',title:'Time Dilation', ins:[], outs:[D('Time Dilation','float')]},
branch: {cat:'flow', title:'Branch', ins:[E('Exec'), D('Condition','bool')], outs:[E('True'), E('False')]},
sequence: {cat:'flow', title:'Sequence', ins:[E('Exec')], outs:[E('Then 0'), E('Then 1')]},
forLoop: {cat:'flow', title:'For Loop', ins:[E('Exec'), D('First Index','int','0'), D('Last Index','int','9')], outs:[E('Loop Body'), D('Index','int'), E('Completed')]},
};
const LEFT_VARS = [['health','float'],['playerName','string'],['timeDilation','float']];
const LEFT_FUNCS = ['beginPlay','tick','anyDamage','printString','delay'];
const $ = id => document.getElementById(id);
const stage=$('stage'), cv=$('cv'), ctx=cv.getContext('2d'), nodeLayer=$('nodeLayer');
const tooltip=$('tooltip'), ctxMenu=$('ctxMenu'), insp=$('insp');
const stCoords=$('stCoords'), stNodes=$('stNodes'), stWires=$('stWires'), stCompile=$('stCompile'), selInfo=$('selInfo');
const btnCompile=$('btnCompile'), zoomPct=$('zoomPct');
const view = {x:0, y:0, k:1};
let sw=0, sh=0, nid=1, wid=1, zTop=10;
const state = {nodes:[], wires:[], pulses:[], selectedNode:null, selectedWire:null, hoverWire:null, hoverNode:null, drag:null, compiling:false};
function svgPin(dir,kind,on){
if(kind==='exec'){
const d = dir==='out' ? 'M3.5 2.5 11.5 7 3.5 11.5Z' : 'M10.5 2.5 2.5 7 10.5 11.5Z';
return `<svg class="pshape${on?' on':''}" viewBox="0 0 14 14" width="11" height="11"><path d="${d}"/></svg>`;
}
return `<svg class="pshape${on?' on':''}" viewBox="0 0 14 14" width="11" height="11"><circle cx="7" cy="7" r="4"/></svg>`;
}
const hexRgb = h => [parseInt(h.slice(1,3),16),parseInt(h.slice(3,5),16),parseInt(h.slice(5,7),16)].join(', ');
const nodeById = id => state.nodes.find(n=>n.id===id);
function pinPos(p){
const r=p.shapeEl.getBoundingClientRect(), s=stage.getBoundingClientRect();
return {x:r.left+r.width/2-s.left, y:r.top+r.height/2-s.top};
}
let rafPending=false;
function requestRender(){ if(!rafPending){rafPending=true;requestAnimationFrame(()=>{rafPending=false;render();});} }
function createNode(type,x,y,opt={}){
const def=CATALOG[type], cat=CATS[def.cat];
const n={id:nid++, type, cat:def.cat, def, x, y, pins:[], el:null};
const rows=Math.max(def.ins.length,def.outs.length,1);
let rowsHtml='', defIdx=0;
for(let r=0;r<rows;r++){
const ip=def.ins[r], op=def.outs[r];
let inHtml='', outHtml='';
if(ip){
const col=PIN_COLORS[ip.kind];
const defVal=(opt.defs&&opt.defs[defIdx]!=null)?opt.defs[defIdx]:(ip.def||'');
inHtml=`<div class="ue-pin ${ip.kind==='exec'?'exec':'data'} in" data-pin="${n.pins.length}" style="--pc:${col}">
${svgPin('in',ip.kind,false)}<span class="plabel">${ip.name}</span>
${ip.kind!=='exec'&&defVal!==''?`<span class="pin-def" contenteditable="true" spellcheck="false">${defVal}</span>`:''}
</div>`;
n.pins.push({node:n,dir:'in',kind:ip.kind,name:ip.name,def:defVal,el:null,shapeEl:null,wires:[]});
defIdx++;
}
if(op){
const col=PIN_COLORS[op.kind];
outHtml=`<div class="ue-pin ${op.kind==='exec'?'exec':'data'} out" data-pin="${n.pins.length}" style="--pc:${col}">
<span class="plabel">${op.name}</span>${svgPin('out',op.kind,false)}
</div>`;
n.pins.push({node:n,dir:'out',kind:op.kind,name:op.name,def:'',el:null,shapeEl:null,wires:[]});
}
rowsHtml+=`<div class="ue-row"><div class="ue-cell in">${inHtml}</div><div class="ue-cell out">${outHtml}</div></div>`;
}
const el=document.createElement('div');
el.className='ue-node'+(opt.spawn?' spawn':'');
el.dataset.id=n.id;
el.style.cssText=`left:${x}px;top:${y}px;--nc:${cat.color};z-index:${zTop}`;
el.innerHTML=`<div class="body">
<div class="ue-head">
<div class="head-spill"></div>
<div class="head-gloss"></div>
<div class="head-highlight"></div>
<span class="icon">${ICONS[def.cat]}</span>
<span class="ue-title">${def.title}</span>
</div>
<div class="ue-rows">${rowsHtml}</div>
</div>`;
nodeLayer.appendChild(el);
n.el=el;
n.pins.forEach((p,i)=>{ p.el=el.querySelector(`[data-pin="${i}"]`); p.shapeEl=p.el.querySelector('.pshape'); });
if(opt.spawn) {
setTimeout(()=>el.classList.remove('spawn'),400);
animate(el, { scale: [0.9, 1], opacity: [0, 1] }, { duration: 0.35, ease: [0.34, 1.2, 0.64, 1] });
}
state.nodes.push(n);
return n;
}
function refreshPin(p){ p.el.classList.toggle('connected', p.wires.length>0); }
function addWire(from,to){
const w={id:wid++, from, to, kind:from.kind};
from.wires.push(w); to.wires.push(w); state.wires.push(w);
refreshPin(from); refreshPin(to);
animate([from.shapeEl, to.shapeEl], { scale: [1, 1.25, 1] }, { duration: 0.25, delay: [0, 0.04] });
return w;
}
function removeWire(w){
state.wires=state.wires.filter(x=>x!==w);
w.from.wires=w.from.wires.filter(x=>x!==w);
w.to.wires=w.to.wires.filter(x=>x!==w);
refreshPin(w.from); refreshPin(w.to);
if(state.selectedWire===w){state.selectedWire=null;updateStats();}
}
function link(a,oi,b,ii){
addWire(a.pins.filter(p=>p.dir==='out')[oi], b.pins.filter(p=>p.dir==='in')[ii]);
}
function removeNode(n){
[...state.wires].forEach(w=>{ if(w.from.node===n||w.to.node===n) removeWire(w); });
animate(n.el, { scale: [1, 0.88], opacity: [1, 0] }, { duration: 0.2 });
setTimeout(()=>{
n.el.remove();
state.nodes=state.nodes.filter(x=>x!==n);
if(state.selectedNode===n){state.selectedNode=null;renderInspector();}
updateStats(); requestRender();
}, 200);
}
function resize(){
const dpr=window.devicePixelRatio||1;
sw=stage.clientWidth; sh=stage.clientHeight;
cv.width=Math.max(1,sw*dpr); cv.height=Math.max(1,sh*dpr);
cv.style.width=sw+'px'; cv.style.height=sh+'px';
ctx.setTransform(dpr,0,0,dpr,0,0);
requestRender();
}
function applyView(){
nodeLayer.style.transform=`translate(${view.x}px,${view.y}px) scale(${view.k})`;
zoomPct.textContent=Math.round(view.k*100)+'%';
}
function zoomAt(px,py,k2){
k2=Math.min(2.5,Math.max(0.35,k2));
view.x=px-(px-view.x)*(k2/view.k); view.y=py-(py-view.y)*(k2/view.k); view.k=k2;
applyView(); requestRender();
}
function fitView(anim){
if(!state.nodes.length) return;
let x0=1e9,y0=1e9,x1=-1e9,y1=-1e9;
for(const n of state.nodes){ x0=Math.min(x0,n.x); y0=Math.min(y0,n.y); x1=Math.max(x1,n.x+n.el.offsetWidth); y1=Math.max(y1,n.y+n.el.offsetHeight); }
const pad=55;
const k=Math.min(1.1,Math.max(0.3,Math.min((sw-pad*2)/(x1-x0),(sh-pad*2)/(y1-y0))));
const tx=(sw-(x1-x0)*k)/2-x0*k, ty=(sh-(y1-y0)*k)/2-y0*k;
if(!anim){ view.x=tx;view.y=ty;view.k=k; applyView(); requestRender(); return; }
const s={...view}, t0=performance.now(), dur=300;
(function step(){
const p=Math.min(1,(performance.now()-t0)/dur), e=1-Math.pow(1-p,3);
view.x=s.x+(tx-s.x)*e; view.y=s.y+(ty-s.y)*e; view.k=s.k+(k-s.k)*e;
applyView(); requestRender();
if(p<1) requestAnimationFrame(step);
})();
}
function drawGrid(){
ctx.fillStyle='#1a1a1a'; ctx.fillRect(0,0,sw,sh);
const k=view.k; let minor=20;
while(minor*k<10) minor*=5; while(minor*k>58) minor/=5;
const gx0=Math.floor((-view.x/k)/minor), gx1=Math.ceil((sw-view.x)/k/minor);
const gy0=Math.floor((-view.y/k)/minor), gy1=Math.ceil((sh-view.y)/k/minor);
ctx.lineWidth=1;
for(let i=gx0;i<=gx1;i++){
const sx=Math.round(i*minor*k+view.x)+.5;
ctx.strokeStyle = i===0 ? 'rgba(255,255,255,.08)' : (i%5===0 ? 'rgba(255,255,255,.045)' : 'rgba(255,255,255,.022)');
ctx.beginPath(); ctx.moveTo(sx,0); ctx.lineTo(sx,sh); ctx.stroke();
}
for(let i=gy0;i<=gy1;i++){
const sy=Math.round(i*minor*k+view.y)+.5;
ctx.strokeStyle = i===0 ? 'rgba(255,255,255,.08)' : (i%5===0 ? 'rgba(255,255,255,.045)' : 'rgba(255,255,255,.022)');
ctx.beginPath(); ctx.moveTo(0,sy); ctx.lineTo(sw,sy); ctx.stroke();
}
}
const bezC = (a,b)=>Math.max(42,Math.abs(b.x-a.x)*.48);
function traceBez(a,b){ const c=bezC(a,b); ctx.beginPath(); ctx.moveTo(a.x,a.y); ctx.bezierCurveTo(a.x+c,a.y,b.x-c,b.y,b.x,b.y); }
function bezPt(a,b,t){ const c=bezC(a,b),u=1-t; return {
x:u*u*u*a.x+3*u*u*t*(a.x+c)+3*u*t*t*(b.x-c)+t*t*t*b.x,
y:u*u*u*a.y+3*u*u*t*a.y+3*u*t*t*b.y+t*t*t*b.y }; }
function strokeWire(w,mode){
const a=pinPos(w.from), b=pinPos(w.to);
const col = w.kind==='exec' ? '#b8b8b8' : PIN_COLORS[w.kind];
ctx.lineCap='round';
ctx.strokeStyle='rgba(0,0,0,.5)'; ctx.lineWidth=mode?4.2:3.2; traceBez(a,b); ctx.stroke();
ctx.shadowColor = mode?col:'transparent'; ctx.shadowBlur = mode?8:0;
ctx.strokeStyle = col; ctx.lineWidth = mode===2?2.6:mode===1?2.2:1.8; traceBez(a,b); ctx.stroke();
ctx.shadowBlur=0;
}
function hitWire(mx,my){
let best=null, bd=60;
for(const w of state.wires){
const a=pinPos(w.from), b=pinPos(w.to);
for(let i=0;i<=22;i++){ const q=bezPt(a,b,i/22), dx=q.x-mx, dy=q.y-my, d=dx*dx+dy*dy; if(d<bd){bd=d;best=w;} }
}
return best;
}
function render(){
if(!sw||!sh) return;
const now=performance.now();
drawGrid();
for(const w of state.wires){
let mode=0;
if(state.hoverNode&&(w.from.node===state.hoverNode||w.to.node===state.hoverNode)) mode=1;
if(state.hoverWire===w) mode=1;
if(state.selectedWire===w) mode=2;
strokeWire(w,mode);
}
state.pulses=state.pulses.filter(p=>now<p.t0+p.dur);
for(const p of state.pulses){
const t=(now-p.t0)/p.dur; if(t<0||t>1) continue;
const a=pinPos(p.w.from), b=pinPos(p.w.to);
ctx.lineCap='round';
ctx.strokeStyle='rgba(200,230,200,.28)'; ctx.lineWidth=3.2; traceBez(a,b); ctx.stroke();
const pt=bezPt(a,b,t);
ctx.save(); ctx.shadowColor='#a0d0a0'; ctx.shadowBlur=14; ctx.fillStyle='#d0f0d0';
ctx.beginPath(); ctx.arc(pt.x,pt.y,3.8,0,Math.PI*2); ctx.fill(); ctx.restore();
}
const d=state.drag;
if(d&&d.type==='wire'){
const a=pinPos(d.pin), b={x:d.mx,y:d.my};
const col = d.pin.kind==='exec' ? '#b8b8b8' : PIN_COLORS[d.pin.kind];
ctx.lineCap='round'; ctx.globalAlpha=.88;
ctx.strokeStyle='rgba(0,0,0,.5)'; ctx.lineWidth=3; traceBez(a,b); ctx.stroke();
ctx.strokeStyle=col; ctx.lineWidth=1.8; traceBez(a,b); ctx.stroke();
ctx.globalAlpha=1;
ctx.strokeStyle=col; ctx.lineWidth=1.4; ctx.beginPath(); ctx.arc(b.x,b.y,3.8,0,Math.PI*2); ctx.stroke();
}
}
function pulseLoop(){ requestRender(); if(state.pulses.length) requestAnimationFrame(pulseLoop); }
function compile(){
if(state.compiling) return;
state.compiling=true;
btnCompile.classList.add('busy'); btnCompile.querySelector('.lbl').textContent='Compiling';
stCompile.textContent='compiling…'; stCompile.className='';
animate(btnCompile, { scale: [1, 1.03, 1] }, { duration: 0.35 });
const depth=new Map(), q=[];
state.nodes.forEach(n=>{ if(CATALOG[n.type].cat==='event'){depth.set(n,0);q.push(n);} });
while(q.length){
const n=q.shift(), d=depth.get(n);
n.pins.forEach(p=>{ if(p.dir==='out'&&p.kind==='exec') p.wires.forEach(w=>{
const t=w.to.node, nd=d+1; if(!depth.has(t)||depth.get(t)>nd){depth.set(t,nd);q.push(t);} }); });
}
const now=performance.now(); let maxD=0; state.pulses=[];
state.wires.forEach(w=>{ if(w.kind!=='exec') return; const d=depth.get(w.from.node); if(d==null) return;
maxD=Math.max(maxD,d); state.pulses.push({w, t0:now+220+d*200, dur:290}); });
depth.forEach((d,n)=>{ setTimeout(()=>{ n.el.classList.add('pulsing'); setTimeout(()=>n.el.classList.remove('pulsing'),520); },220+d*200); });
requestAnimationFrame(pulseLoop);
setTimeout(()=>{
state.compiling=false; btnCompile.classList.remove('busy'); btnCompile.querySelector('.lbl').textContent='Compile';
stCompile.textContent='✓ compiled '+new Date().toLocaleTimeString([],{hour12:false}); stCompile.className='ok';
animate(btnCompile, { scale: [1, 1.08, 1] }, { duration: 0.25, delay: 0.08 });
},220+maxD*200+380);
}
function selectNode(n){
if(state.selectedNode&&state.selectedNode.el) state.selectedNode.el.classList.remove('selected');
state.selectedNode=n; state.selectedWire=null;
if(n) {
n.el.classList.add('selected');
animate(n.el, { scale: [1, 1.015, 1] }, { duration: 0.18 });
}
renderInspector(); updateStats(); requestRender();
}
function selectWire(w){
if(state.selectedNode){state.selectedNode.el.classList.remove('selected');state.selectedNode=null;}
state.selectedWire=w; renderInspector(); updateStats(); requestRender();
}
function defaultInspector(){
const rows=Object.entries(PIN_COLORS).map(([k,c])=>`
<div class="pin-row" style="--pc:${c}">${svgPin('out',k,true)}<span class="nm">${KIND_LABEL[k]}</span><span class="type-chip">${PC_NAMES[k]}</span></div>`).join('');
return `<div class="insp-pad">
<div class="empty"><span class="cross">◎</span>Nothing selected<br><span class="sub">click a node to inspect</span></div>
<div class="insp-sec">Pin Types</div>${rows}
<div class="insp-src">UE5 pin type colors · UEdGraphSchema_K2</div>
</div>`;
}
function nodeInspector(n){
const cat=CATS[n.cat];
const pins=n.pins.map(p=>{
const c=PIN_COLORS[p.kind];
return `<div class="pin-row" style="--pc:${c}">${svgPin(p.dir,p.kind,p.kind!=='exec')}<span class="nm">${p.name}</span><span class="type-chip">${PC_NAMES[p.kind]}</span><span class="dir">${p.dir}</span></div>`;
}).join('');
return `<div class="insp-pad" style="--nc:${cat.color}">
<span class="insp-cat"><i></i>${cat.label}</span>
<div class="insp-title">${n.def.title}</div>
<div class="insp-rgb"><span class="swatch"></span>rgb(${hexRgb(cat.color)})</div>
<div class="insp-sec">Title Layers</div>
<div class="brush"><span class="bar" style="background:radial-gradient(115% 145% at 22% 28%,color-mix(in srgb,var(--nc) 48%,transparent),transparent 82%),#242428"></span><div><code>ColorSpill</code><div class="ds">radial glow</div></div></div>
<div class="brush"><span class="bar" style="background:linear-gradient(180deg,rgba(255,255,255,.38),rgba(255,255,255,.04) 52%,rgba(0,0,0,.15))"></span><div><code>Gloss</code><div class="ds">specular highlight</div></div></div>
<div class="brush"><span class="bar" style="background:linear-gradient(180deg,rgba(255,255,255,.28),transparent 2px),#242428"></span><div><code>Highlight</code><div class="ds">top edge</div></div></div>
<div class="insp-sec">Pins · ${n.pins.length}</div>
${pins}
<div class="insp-src">UE5 Blueprint · SGraphNode.cpp</div>
</div>`;
}
function renderInspector(){
insp.innerHTML = state.selectedNode ? nodeInspector(state.selectedNode) : defaultInspector();
if(state.selectedNode) {
const inspPad = insp.querySelector('.insp-pad');
if(inspPad) animate(inspPad, { opacity: [0, 1], y: [6, 0] }, { duration: 0.22 });
}
}
function updateStats(){
stNodes.textContent=state.nodes.length+' nodes';
stWires.textContent=state.wires.length+' wires';
document.querySelectorAll('.cat-row').forEach(r=>{ const c=r.dataset.cat; r.querySelector('.ct').textContent=state.nodes.filter(n=>n.cat===c).length; });
selInfo.textContent = state.selectedWire ? 'wire · '+PC_NAMES[state.selectedWire.kind] : state.selectedNode ? 'node · '+state.selectedNode.def.title : '';
selInfo.className = state.selectedWire||state.selectedNode ? 'sel' : '';
}
function showTipAt(mx,my,html){
tooltip.innerHTML=html; const w=tooltip.offsetWidth||150;
tooltip.style.left=Math.min(mx+14, sw-w-8)+'px'; tooltip.style.top=Math.max(6,my-12)+'px';
tooltip.classList.add('show');
animate(tooltip, { opacity: [0, 1], y: [3, 0] }, { duration: 0.1 });
}
function showPinTip(pinEl,mx,my){
const n=nodeById(+pinEl.closest('.ue-node').dataset.id); const p=n.pins[+pinEl.dataset.pin];
showTipAt(mx,my,`<b>${p.name}</b><br><span class="sub">${KIND_LABEL[p.kind]} · ${PC_NAMES[p.kind]} · ${p.dir==='in'?'input':'output'}</span>`);
}
const hideTip=()=>{
tooltip.classList.remove('show');
animate(tooltip, { opacity: 0, y: 3 }, { duration: 0.08 });
};
let ctxGraph={x:0,y:0};
function buildCtx(){
const order=['event','function','pure','variable','flow'];
let html=`<div class="ctx-head">Add Node <span>EventGraph</span></div>`;
for(const c of order){
html+=`<div class="ctx-cat" style="--cc:${CATS[c].color}"><i></i>${CATS[c].label}</div>`;
for(const [key,def] of Object.entries(CATALOG)){
if(def.cat!==c) continue;
html+=`<button class="ctx-item" data-type="${key}" style="--cc:${CATS[c].color}">${ICONS[c]}${def.title}</button>`;
}
}
ctxMenu.innerHTML=html;
}
function openCtx(clientX,clientY){
const r=stage.getBoundingClientRect();
ctxGraph={x:(clientX-r.left-view.x)/view.k, y:(clientY-r.top-view.y)/view.k};
let lx=clientX-r.left, ly=clientY-r.top;
ctxMenu.classList.add('open');
const mw=ctxMenu.offsetWidth, mh=ctxMenu.offsetHeight;
lx=Math.min(lx, sw-mw-6); ly=Math.min(ly, sh-mh-6);
ctxMenu.style.left=Math.max(3,lx)+'px'; ctxMenu.style.top=Math.max(3,ly)+'px';
animate(ctxMenu, { opacity: [0, 1], scale: [0.97, 1], y: [-4, 0] }, { duration: 0.12, ease: [0.2, 0.75, 0.2, 1] });
animate('.ctx-item', { opacity: [0, 1], x: [-6, 0] }, { duration: 0.1, delay: 0.015 });
}
const closeCtx=()=>{
animate(ctxMenu, { opacity: 0, scale: 0.97 }, { duration: 0.08 });
setTimeout(() => ctxMenu.classList.remove('open'), 80);
};
ctxMenu.addEventListener('click',e=>{
const item=e.target.closest('.ctx-item'); if(!item) return;
const n=createNode(item.dataset.type, ctxGraph.x-88, ctxGraph.y-20, {spawn:true});
closeCtx(); selectNode(n); updateStats(); requestRender();
});
nodeLayer.addEventListener('mousedown',e=>{
const nodeEl=e.target.closest('.ue-node'); if(!nodeEl) return;
e.stopPropagation();
const n=nodeById(+nodeEl.dataset.id);
if(e.target.closest('.pin-def')) return;
selectNode(n);
const pinEl=e.target.closest('.ue-pin');
if(pinEl&&e.button===0){ startWireDrag(n,pinEl,e); return; }
if(e.button!==0) return;
n.el.style.zIndex=++zTop;
state.drag={type:'node',node:n,sx:e.clientX,sy:e.clientY,ox:n.x,oy:n.y,moved:false};
n.el.classList.add('dragging');
animate(n.el, { scale: 0.985 }, { duration: 0.08 });
hideTip();
});
function startWireDrag(n,pinEl,e){
e.preventDefault();
const pin=n.pins[+pinEl.dataset.pin];
if(e.altKey){ [...pin.wires].forEach(removeWire); updateStats(); requestRender(); return; }
let startPin=pin;
if(pin.dir==='in'&&pin.wires.length){ const w=pin.wires[0]; removeWire(w); startPin=w.from; updateStats(); }
const r=stage.getBoundingClientRect();
state.drag={type:'wire',pin:startPin,mx:e.clientX-r.left,my:e.clientY-r.top};
startPin.el.classList.add('active');
animate(startPin.shapeEl, { scale: [1, 1.2] }, { duration: 0.12 });
hideTip();
}
stage.addEventListener('mousedown',e=>{
if(e.target.closest('#zoomHud')||e.target.closest('#ctxMenu')) return;
closeCtx();
if(e.button===1) e.preventDefault();
const r=stage.getBoundingClientRect(); const mx=e.clientX-r.left, my=e.clientY-r.top;
if(e.button===0){ const w=hitWire(mx,my); if(w){ selectWire(w); return; } }
if(e.button===0||e.button===1){ state.drag={type:'pan',sx:e.clientX,sy:e.clientY,ox:view.x,oy:view.y,moved:false}; stage.classList.add('panning'); }
});
nodeLayer.addEventListener('mouseover',e=>{
const nodeEl=e.target.closest('.ue-node');
state.hoverNode=nodeEl?nodeById(+nodeEl.dataset.id):null;
const pinEl=e.target.closest('.ue-pin');
if(pinEl&&!state.drag){
const r=stage.getBoundingClientRect();
showPinTip(pinEl,e.clientX-r.left,e.clientY-r.top);
const pin=nodeById(+pinEl.closest('.ue-node').dataset.id).pins[+pinEl.dataset.pin];
animate(pin.shapeEl, { scale: 1.15 }, { duration: 0.12 });
} else hideTip();
requestRender();
});
nodeLayer.addEventListener('mouseout',e=>{
if(e.relatedTarget&&nodeLayer.contains(e.relatedTarget)){ if(!e.relatedTarget.closest('.ue-pin')) hideTip(); return; }
state.hoverNode=null; hideTip();
state.nodes.forEach(n => n.pins.forEach(p => {
if(p.shapeEl && !p.el.matches(':hover')) {
animate(p.shapeEl, { scale: 1 }, { duration: 0.12 });
}
}));
requestRender();
});
window.addEventListener('mousemove',e=>{
const r=stage.getBoundingClientRect(); const mx=e.clientX-r.left, my=e.clientY-r.top;
const gx=(mx-view.x)/view.k, gy=(my-view.y)/view.k;
const fmt=v=>(v<0?'−':'+')+String(Math.abs(Math.round(v))).padStart(4,'0');
if(mx>=0&&my>=0&&mx<=sw&&my<=sh) stCoords.textContent=`X ${fmt(gx)} · Y ${fmt(gy)}`;
const d=state.drag;
if(!d){
if(e.target===cv){
const w=hitWire(mx,my);
if(w!==state.hoverWire){state.hoverWire=w;requestRender();}
cv.style.cursor=w?'pointer':'';
if(w) showTipAt(mx,my,`<b>${w.kind==='exec'?'Exec':'Data'} wire</b><br><span class="sub">${PC_NAMES[w.kind]} · click select · ⌫ remove</span>`); else hideTip();
} else if(state.hoverWire){ state.hoverWire=null; requestRender(); }
return;
}
hideTip();
if(d.type==='pan'){
const dx=e.clientX-d.sx, dy=e.clientY-d.sy; if(Math.abs(dx)+Math.abs(dy)>3) d.moved=true;
view.x=d.ox+dx; view.y=d.oy+dy; applyView(); requestRender();
} else if(d.type==='node'){
const dx=(e.clientX-d.sx)/view.k, dy=(e.clientY-d.sy)/view.k; if(Math.abs(dx)+Math.abs(dy)>1.5/view.k) d.moved=true;
d.node.x=d.ox+dx; d.node.y=d.oy+dy; d.node.el.style.left=d.node.x+'px'; d.node.el.style.top=d.node.y+'px'; requestRender();
} else if(d.type==='wire'){ d.mx=mx; d.my=my; requestRender(); }
});
window.addEventListener('mouseup',e=>{
const d=state.drag; if(!d) return;
if(d.type==='pan'){ if(!d.moved){ selectNode(null); selectWire(null); } stage.classList.remove('panning'); }
else if(d.type==='node'){
d.node.el.classList.remove('dragging');
animate(d.node.el, { scale: [0.985, 1] }, { duration: 0.18, ease: [0.34, 1.4, 0.64, 1] });
}
else if(d.type==='wire'){
animate(d.pin.shapeEl, { scale: [1.2, 1] }, { duration: 0.12 });
finishWire(d,e);
}
state.drag=null; requestRender();
});
function finishWire(d,e){
d.pin.el.classList.remove('active');
const r=stage.getBoundingClientRect(); const mx=e.clientX-r.left, my=e.clientY-r.top;
let best=null, bd=15*15;
for(const n of state.nodes) for(const p of n.pins){
if(p===d.pin||p.node===d.pin.node||p.dir===d.pin.dir||p.kind!==d.pin.kind) continue;
const q=pinPos(p), dx=q.x-mx, dy=q.y-my, dd=dx*dx+dy*dy; if(dd<bd){bd=dd;best=p;}
}
if(best){
const out=d.pin.dir==='out'?d.pin:best, inn=d.pin.dir==='out'?best:d.pin;
[...inn.wires].forEach(removeWire); addWire(out,inn);
} else {
const el=document.elementFromPoint(e.clientX,e.clientY);
const pe=el&&el.closest?el.closest('.ue-pin'):null;
if(pe){
pe.classList.add('reject');
animate(pe, { x: [0, -3, 3, -3, 3, 0] }, { duration: 0.25 });
setTimeout(()=>pe.classList.remove('reject'),320);
}
}
updateStats();
}
stage.addEventListener('wheel',e=>{ e.preventDefault(); const r=stage.getBoundingClientRect(); zoomAt(e.clientX-r.left, e.clientY-r.top, view.k*Math.exp(-e.deltaY*0.0012)); },{passive:false});
stage.addEventListener('contextmenu',e=>{ e.preventDefault(); if(e.target.closest('#zoomHud')) return; openCtx(e.clientX,e.clientY); });
stage.addEventListener('dblclick',e=>{ if(e.target===cv) openCtx(e.clientX,e.clientY); });
stage.addEventListener('mouseleave',()=>{ hideTip(); if(!state.drag){ state.hoverWire=null; state.hoverNode=null; requestRender(); } });
window.addEventListener('mousedown',e=>{ if(!e.target.closest('#ctxMenu')) closeCtx(); },true);
window.addEventListener('keydown',e=>{
const editing=e.target.isContentEditable||/INPUT|TEXTAREA/.test(e.target.tagName);
if(e.key==='Escape'){
closeCtx();
if(state.drag&&state.drag.type==='wire'){
animate(state.drag.pin.shapeEl, { scale: 1 }, { duration: 0.12 });
state.drag.pin.el.classList.remove('active');
state.drag=null;
requestRender();
}
else { selectNode(null); selectWire(null); }
return;
}
if(editing) return;
if(e.key==='Delete'||e.key==='Backspace'){
if(state.selectedNode) removeNode(state.selectedNode);
else if(state.selectedWire){ removeWire(state.selectedWire); updateStats(); requestRender(); }
} else if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='d'){ e.preventDefault(); if(state.selectedNode) duplicateNode(state.selectedNode); }
else if(e.key.toLowerCase()==='f'){ fitView(true); }
});
function duplicateNode(n){
n.pins.forEach(p=>{ if(p.el){ const d=p.el.querySelector('.pin-def'); if(d) p.def=d.textContent.trim(); } });
const defs=n.pins.filter(p=>p.dir==='in'&&p.kind!=='exec').map(p=>p.def||null);
const c=createNode(n.type, n.x+32, n.y+32, {spawn:true, defs});
selectNode(c); updateStats(); requestRender();
}
document.querySelectorAll('.tb-btn, .zh-btn').forEach(btn => {
btn.addEventListener('mouseenter', () => animate(btn, { scale: 1.03 }, { duration: 0.1 }));
btn.addEventListener('mouseleave', () => animate(btn, { scale: 1 }, { duration: 0.1 }));
btn.addEventListener('mousedown', () => animate(btn, { scale: 0.97 }, { duration: 0.06 }));
btn.addEventListener('mouseup', () => animate(btn, { scale: 1.03 }, { duration: 0.06 }));
});
document.querySelectorAll('.item, .cat-row').forEach(item => {
item.addEventListener('mouseenter', () => animate(item, { x: 2 }, { duration: 0.1 }));
item.addEventListener('mouseleave', () => animate(item, { x: 0 }, { duration: 0.1 }));
});
$('btnAdd').addEventListener('click',e=>{ const r=e.currentTarget.getBoundingClientRect(); openCtx(r.left, r.bottom+6); });
$('btnFit').addEventListener('click',()=>{
animate($('btnFit'), { scale: [1, 0.96, 1] }, { duration: 0.18 });
fitView(true);
});
btnCompile.addEventListener('click',compile);
$('zIn').addEventListener('click',()=>{
animate($('zIn'), { scale: [1, 1.15, 1] }, { duration: 0.12 });
zoomAt(sw/2,sh/2,view.k*1.25);
});
$('zOut').addEventListener('click',()=>{
animate($('zOut'), { scale: [1, 1.15, 1] }, { duration: 0.12 });
zoomAt(sw/2,sh/2,view.k*0.8);
});
zoomPct.addEventListener('click',()=>{
animate(zoomPct, { scale: [1, 0.92, 1] }, { duration: 0.12 });
zoomAt(sw/2,sh/2,1);
});
$('zFit').addEventListener('click',()=>{
animate($('zFit'), { rotate: [0, 90] }, { duration: 0.25 });
fitView(true);
});
$('tglLeft').addEventListener('click',e=>{
$('app').classList.toggle('hide-left');
e.currentTarget.classList.toggle('toggled');
animate($('left'), { x: $('app').classList.contains('hide-left') ? -220 : 0 }, { duration: 0.25, ease: [0.2, 0.75, 0.2, 1] });
});
$('tglRight').addEventListener('click',e=>{
$('app').classList.toggle('hide-right');
e.currentTarget.classList.toggle('toggled');
animate($('right'), { x: $('app').classList.contains('hide-right') ? 245 : 0 }, { duration: 0.25, ease: [0.2, 0.75, 0.2, 1] });
});
document.querySelectorAll('.cat-row').forEach(row=>{
row.addEventListener('click',()=>{
const c=row.dataset.cat;
state.nodes.filter(n=>n.cat===c).forEach((n, i) =>{
n.el.classList.add('catflash');
animate(n.el, {
scale: [1, 1.025, 1],
filter: ['brightness(1)', 'brightness(1.15)', 'brightness(1)']
}, { duration: 0.35, delay: i * 0.04 });
setTimeout(()=>n.el.classList.remove('catflash'),800);
});
});
});
$('leftVars').innerHTML=LEFT_VARS.map(([key,kind])=>`<div class="item" data-spawn="${key}"><span class="chip" style="--pc:${PIN_COLORS[kind]}">${kind}</span>${CATALOG[key].title}</div>`).join('');
$('leftFuncs').innerHTML=LEFT_FUNCS.map(key=>`<div class="item" data-spawn="${key}"><span class="it-ic">${ICONS[CATALOG[key].cat]}</span>${CATALOG[key].title}</div>`).join('');
document.querySelectorAll('[data-spawn]').forEach(b=>{
b.addEventListener('click',()=>{
const gx=(sw/2-view.x)/view.k, gy=(sh/2-view.y)/view.k;
const n=createNode(b.dataset.spawn, gx-88+(Math.random()*38-19), gy-26+(Math.random()*28-14), {spawn:true});
selectNode(n); updateStats(); requestRender();
});
});
buildCtx();
resize();
new ResizeObserver(resize).observe(stage);
const N={};
N.begin = createNode('beginPlay', 40, 60);
N.print = createNode('printString', 380, 50, {defs:['Hello, Blueprint!']});
N.tick = createNode('tick', 40, 300);
N.branch = createNode('branch', 640, 280);
N.less = createNode('less', 330, 470, {defs:[null,'30.0']});
N.health = createNode('health', 60, 500);
N.getLoc = createNode('getActorLocation',640, 540);
N.setLoc = createNode('setActorLocation',980, 240);
N.delay = createNode('delay', 980, 470);
N.print2 = createNode('printString', 1300, 460, {defs:['Health low!']});
link(N.begin,0, N.print,0);
link(N.tick,0, N.branch,0);
link(N.less,0, N.branch,1);
link(N.health,0,N.less,0);
link(N.branch,0,N.setLoc,0);
link(N.getLoc,0,N.setLoc,1);
link(N.branch,1,N.delay,0);
link(N.delay,0, N.print2,0);
fitView(false);
renderInspector();
updateStats();
requestRender();
setTimeout(compile, 900);
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment