Created
August 19, 2026 08:49
-
-
Save CypherpunkSamurai/1ed8f3c20d46c24e6948903da5a6ee0c to your computer and use it in GitHub Desktop.
Unreal Engine node Editor
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!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> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!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