Files
2026-04-25 19:46:01 +03:00

1363 lines
37 KiB
JavaScript

import { app } from "../../../scripts/app.js";
import { NodeInvadersGame } from "./game.js";
const EXTENSION_NAME = "Comfy.NodeInvaders.Game.Panel";
const PANEL_ID = "nodeinvaders-panel";
const BUTTON_ID = "nodeinvaders-toggle-button";
const STYLE_ID = "nodeinvaders-style";
let gamePanel = null;
let gameButton = null;
let gameInstance = null;
let resizeHandler = null;
let currentMode = "api";
export function setGameMode(mode) {
if (mode !== "api" && mode !== "all") return;
if (currentMode !== mode) {
currentMode = mode;
clearNodeTypesCache();
}
}
export function getGameMode() {
return currentMode;
}
export const BOSS_TYPE = {
name: "BOSS",
typeCandidates: [],
color: "#1a0033",
header: "#3b0764",
accent: "#f0abfc",
stroke: "#c084fc",
hp: 32,
size: 320,
speed: 22,
seek: 6,
orbit: 5,
wobble: 0.4,
wobbleSpeed: 0.8,
canShoot: true,
fireInterval: 0.85,
points: 50,
weight: 0,
portsIn: 0,
portsOut: 0,
isBoss: true
};
const NODE_TYPES = [
{
name: "KSampler",
typeCandidates: ["KSampler"],
color: "#ff5e5b",
header: "#3a1414",
accent: "#ffd166",
stroke: "#ffb3b1",
hp: 6,
size: 86,
speed: 40,
seek: 14,
orbit: 4,
wobble: 0.4,
wobbleSpeed: 1.2,
canShoot: false,
fireInterval: 3.2,
points: 3,
weight: 1.1,
portsIn: 3,
portsOut: 2
},
{
name: "CLIP",
typeCandidates: ["CLIPTextEncode", "CLIPTextEncodeSDXL", "CLIPTextEncodeSDXLRefiner", "CLIPSetLastLayer"],
color: "#72d884",
header: "#0f2b1f",
accent: "#b6ff70",
stroke: "#a6f4bf",
hp: 2,
size: 50,
speed: 125,
seek: 8,
orbit: 18,
wobble: 1.8,
wobbleSpeed: 3.2,
canShoot: true,
fireInterval: 1.6,
points: 2,
weight: 2.2,
portsIn: 2,
portsOut: 1
},
{
name: "VAE",
typeCandidates: ["VAEDecode", "VAEEncode", "VAELoader"],
color: "#4ea8de",
header: "#0d1b2a",
accent: "#8be9fd",
stroke: "#7fc7ff",
hp: 5,
size: 102,
speed: 32,
seek: 10,
orbit: 0,
wobble: 0.3,
wobbleSpeed: 0.8,
canShoot: false,
fireInterval: 3.4,
points: 3,
weight: 1.0,
portsIn: 4,
portsOut: 2
},
{
name: "Load Image",
typeCandidates: ["LoadImage", "LoadImageMask"],
color: "#f6bd60",
header: "#3a2a14",
accent: "#ffd27d",
stroke: "#ffe0a3",
hp: 3,
size: 72,
speed: 70,
seek: 9,
orbit: 8,
wobble: 1.0,
wobbleSpeed: 2.2,
canShoot: false,
fireInterval: 2.8,
points: 2,
weight: 1.6,
portsIn: 2,
portsOut: 2
},
{
name: "ControlNet",
typeCandidates: ["ControlNetLoader", "ControlNetApply", "ControlNetApplyAdvanced", "ControlNet"],
color: "#48cae4",
header: "#0b2530",
accent: "#80ffea",
stroke: "#9be5ff",
hp: 4,
size: 78,
speed: 62,
seek: 10,
orbit: -16,
wobble: 0.8,
wobbleSpeed: 2.0,
canShoot: true,
fireInterval: 2.0,
points: 3,
weight: 1.4,
portsIn: 3,
portsOut: 3
},
{
name: "LoRA",
typeCandidates: ["LoraLoader", "LoraLoaderModelOnly", "LoraLoaderStack", "LoraLoaderSimple", "LoraLoaderTags"],
color: "#ff9f1c",
header: "#3a2100",
accent: "#ffbf69",
stroke: "#ffd29b",
hp: 3,
size: 64,
speed: 92,
seek: 12,
orbit: 12,
wobble: 1.2,
wobbleSpeed: 2.6,
canShoot: true,
fireInterval: 2.3,
points: 2,
weight: 1.8,
portsIn: 2,
portsOut: 2
},
{
name: "Empty Latent",
typeCandidates: ["EmptyLatentImage", "EmptyLatent"],
color: "#9aa0a6",
header: "#202124",
accent: "#e8eaed",
stroke: "#c7c9cc",
hp: 1,
size: 44,
speed: 85,
seek: 14,
orbit: -4,
wobble: 2.4,
wobbleSpeed: 3.4,
canShoot: false,
fireInterval: 2.6,
points: 1,
weight: 3.0,
portsIn: 1,
portsOut: 1
}
];
export function rand(min, max) {
return Math.random() * (max - min) + min;
}
export function clamp(value, min, max) {
return Math.min(max, Math.max(min, value));
}
function hslToHex(h, s, l) {
l /= 100;
const a = s * Math.min(l, 1 - l) / 100;
const f = n => {
const k = (n + h / 30) % 12;
const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
return Math.round(255 * color).toString(16).padStart(2, '0');
};
return `#${f(0)}${f(8)}${f(4)}`;
}
function darkenColor(hex, amount = 0.3) {
const num = parseInt(hex.replace("#", ""), 16);
const r = Math.round(Math.max(0, Math.min(255, (num >> 16) - amount * 255)));
const g = Math.round(Math.max(0, Math.min(255, ((num >> 8) & 0x00FF) - amount * 255)));
const b = Math.round(Math.max(0, Math.min(255, (num & 0x0000FF) - amount * 255)));
return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
}
function lightenColor(hex, amount = 0.3) {
const num = parseInt(hex.replace("#", ""), 16);
const r = Math.round(Math.min(255, (num >> 16) + amount * 255));
const g = Math.round(Math.min(255, ((num >> 8) & 0x00FF) + amount * 255));
const b = Math.round(Math.min(255, (num & 0x0000FF) + amount * 255));
return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
}
function createTypeFromNodeName(nodeName) {
const hue = Math.random() * 360;
const sat = 60 + Math.random() * 40;
const light = 40 + Math.random() * 30;
const colorHex = hslToHex(hue, sat, light);
const headerHex = darkenColor(colorHex, 0.5);
const accentHex = lightenColor(colorHex, 0.4);
const strokeHex = lightenColor(colorHex, 0.2);
const hash = nodeName.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0);
const baseHp = 1 + (hash % 6);
const baseSize = 44 + (hash % 60);
const baseSpeed = 30 + (hash % 100);
return {
name: nodeName,
typeCandidates: [nodeName],
color: colorHex,
header: headerHex,
accent: accentHex,
stroke: strokeHex,
hp: baseHp,
size: baseSize,
speed: baseSpeed,
seek: 8 + (hash % 8),
orbit: (hash % 20) - 10,
wobble: 0.3 + (hash % 200) / 100,
wobbleSpeed: 0.8 + (hash % 300) / 100,
canShoot: (hash % 3) === 0,
fireInterval: 1.6 + (hash % 200) / 100,
points: Math.max(1, Math.floor(baseHp / 2)),
weight: 1.0,
portsIn: 1 + (hash % 4),
portsOut: 1 + (hash % 3)
};
}
let allNodeTypesCache = null;
export function clearNodeTypesCache() {
allNodeTypesCache = null;
}
function isApiNode(nodeName, nodeDef) {
if (nodeDef?.category) {
const cat = String(nodeDef.category).toLowerCase().trim();
if (cat.startsWith("api")) return true;
if (cat.includes("/api/") || cat.includes("api node") || cat.includes("api-")) return true;
}
const lower = nodeName.toLowerCase();
const apiVendors = [
"openai", "anthropic", "stability", "stabilityai",
"gemini", "veo", "imagen", "luma", "kling",
"runway", "minimax", "ideogram", "recraft",
"pika", "bfl", "fluxapi", "blackforestlabs",
"tripo", "rodin"
];
for (const vendor of apiVendors) {
if (lower.includes(vendor)) return true;
}
return false;
}
function hasComplexJsUi(nodeName, nodeDef) {
if (!nodeDef) return false;
const proto = nodeDef.prototype;
if (proto) {
const drawHooks = [
"onDrawForeground",
"onDrawBackground",
"onDrawTitle",
"onDrawTitleBar"
];
for (const hook of drawHooks) {
if (Object.prototype.hasOwnProperty.call(proto, hook)) return true;
}
if (Object.prototype.hasOwnProperty.call(proto, "addDOMWidget")) return true;
}
const lower = nodeName.toLowerCase();
const heavyUiNames = [
"preview", "previewimage",
"showimage", "imageshow",
"saveimage", "saveanimatedwebp", "saveanimatedpng",
"loadvideo", "savevideo",
"imagecrop", "imagepad",
"maskeditor", "imageinpaint"
];
for (const pat of heavyUiNames) {
if (lower === pat || lower.includes(pat)) return true;
}
return false;
}
function isCustomNode(nodeName, nodeDef) {
if (nodeDef?.category) {
const category = nodeDef.category.toLowerCase();
if (category.includes('custom') ||
category.includes('extension') ||
category.includes('plugin')) {
return true;
}
}
const nameLower = nodeName.toLowerCase();
const customPatterns = [
'custom',
'extension',
'plugin',
'addon',
'thirdparty',
'third-party'
];
for (const pattern of customPatterns) {
if (nameLower.includes(pattern)) {
return true;
}
}
if (nodeDef?.meta?.author &&
!nodeDef.meta.author.toLowerCase().includes('comfyui') &&
!nodeDef.meta.author.toLowerCase().includes('comfy')) {
return true;
}
return false;
}
function isInternalNode(nodeName) {
return nodeName.startsWith('_') ||
nodeName.includes('Reroute') ||
nodeName === 'Note' ||
nodeName.includes('PrimitiveNode') ||
nodeName === 'Primitive';
}
function getAllAvailableNodeTypes() {
if (allNodeTypesCache) {
return allNodeTypesCache;
}
const registered = getRegisteredNodeTypes();
const allTypes = [];
if (currentMode === "api") {
for (const nodeName in registered) {
if (isInternalNode(nodeName)) continue;
const nodeDef = registered[nodeName];
if (!isApiNode(nodeName, nodeDef)) continue;
const typeInfo = createTypeFromNodeName(nodeName);
typeInfo.isApiNode = true;
allTypes.push(typeInfo);
}
if (allTypes.length === 0) {
for (const type of NODE_TYPES) {
const available = pickAvailableNodeType(type.typeCandidates);
if (available) {
allTypes.push({ ...type, typeCandidates: [available] });
}
}
}
} else {
for (const type of NODE_TYPES) {
const available = pickAvailableNodeType(type.typeCandidates);
if (available) {
allTypes.push({ ...type, typeCandidates: [available] });
}
}
for (const nodeName in registered) {
const alreadyIncluded = NODE_TYPES.some(type =>
type.typeCandidates.includes(nodeName)
);
if (alreadyIncluded) continue;
if (isInternalNode(nodeName)) continue;
const nodeDef = registered[nodeName];
if (isCustomNode(nodeName, nodeDef)) continue;
if (hasComplexJsUi(nodeName, nodeDef)) continue;
allTypes.push(createTypeFromNodeName(nodeName));
}
}
allNodeTypesCache = allTypes;
return allTypes;
}
export function pickWeightedType() {
const allTypes = getAllAvailableNodeTypes();
if (allTypes.length === 0) {
let total = 0;
for (const type of NODE_TYPES) {
total += type.weight;
}
let roll = Math.random() * total;
for (const type of NODE_TYPES) {
if (roll < type.weight) {
return type;
}
roll -= type.weight;
}
return NODE_TYPES[0];
}
let total = 0;
for (const type of allTypes) {
total += type.weight || 1.0;
}
let roll = Math.random() * total;
for (const type of allTypes) {
const weight = type.weight || 1.0;
if (roll < weight) {
return type;
}
roll -= weight;
}
return allTypes[0];
}
export function getLiteGraph() {
return window.LiteGraph || globalThis.LiteGraph;
}
function getRegisteredNodeTypes() {
const liteGraph = getLiteGraph();
return liteGraph?.registered_node_types || {};
}
export function pickAvailableNodeType(candidates) {
const registered = getRegisteredNodeTypes();
for (const candidate of candidates) {
if (registered?.[candidate]) {
return candidate;
}
}
return null;
}
export function createGraphNode(typeName) {
const liteGraph = getLiteGraph();
if (!liteGraph?.createNode) {
return null;
}
return liteGraph.createNode(typeName);
}
function injectStyles() {
if (document.getElementById(STYLE_ID)) {
return;
}
const style = document.createElement("style");
style.id = STYLE_ID;
style.textContent = `
#${PANEL_ID} {
display: none;
z-index: 60;
pointer-events: none;
}
#${PANEL_ID} * {
box-sizing: border-box;
}
#nodeinvaders-container {
position: absolute;
inset: 0;
overflow: hidden;
pointer-events: auto;
background: transparent;
color: #eaf2ff;
font-family: "Segoe UI Variable", "Segoe UI", Inter, ui-sans-serif, system-ui, sans-serif;
user-select: none;
}
#nodeinvaders-canvas {
width: 100%;
height: 100%;
display: block;
}
#nodeinvaders-ui {
position: absolute;
top: 72px;
left: 50%;
transform: translateX(-50%);
width: min(760px, calc(100vw - 180px));
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
z-index: 4;
pointer-events: none;
}
.ni-brand {
font-family: "Segoe UI Variable Display", "Segoe UI", Inter, ui-sans-serif, system-ui, sans-serif;
font-size: 13px;
font-weight: 850;
letter-spacing: 0.14em;
text-transform: uppercase;
background: linear-gradient(120deg, #4ade80 0%, #60a5fa 50%, #c084fc 100%);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
color: transparent;
text-shadow: 0 0 14px rgba(96, 165, 250, 0.38);
filter: drop-shadow(0 2px 5px rgba(0, 0, 0, 0.72));
}
.ni-mode-pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 8px;
border-radius: 7px;
background: rgba(7, 10, 16, 0.78);
border: 1px solid rgba(192, 132, 252, 0.45);
font-size: 10px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: #f0abfc;
width: fit-content;
backdrop-filter: blur(6px);
}
.ni-mode-pill::before {
content: "";
width: 6px;
height: 6px;
border-radius: 50%;
background: #f0abfc;
box-shadow: 0 0 8px #f0abfc;
}
.ni-stats {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
max-width: 100%;
padding: 8px 12px;
border-radius: 8px;
background: linear-gradient(135deg, rgba(6, 9, 15, 0.88), rgba(15, 18, 30, 0.8));
border: 1px solid rgba(120, 140, 180, 0.22);
box-shadow: 0 8px 22px rgba(0, 0, 0, 0.42), inset 0 1px 0 rgba(255, 255, 255, 0.05);
backdrop-filter: blur(8px);
font-size: 11px;
font-weight: 650;
letter-spacing: 0.04em;
white-space: nowrap;
}
.ni-stat {
position: relative;
}
.ni-stat + .ni-stat::before {
content: "";
position: absolute;
left: -7px;
top: 2px;
bottom: 2px;
width: 1px;
background: linear-gradient(180deg, transparent, rgba(192, 132, 252, 0.35), transparent);
}
.ni-stat {
display: flex;
gap: 6px;
align-items: center;
}
.ni-value {
color: #ffd166;
font-weight: 800;
font-variant-numeric: tabular-nums;
}
.ni-lives {
min-width: 56px;
gap: 4px;
}
.ni-life {
display: inline-flex;
align-items: center;
justify-content: center;
width: 15px;
height: 15px;
font-size: 14px;
line-height: 1;
transform: translateY(-1px);
text-shadow: 0 0 10px rgba(255, 94, 91, 0.75);
}
.ni-life.on {
color: #ff5e5b;
}
.ni-life.off {
color: rgba(255, 255, 255, 0.22);
text-shadow: none;
}
.ni-hint {
max-width: min(680px, 100%);
padding: 5px 10px;
border-radius: 7px;
background: rgba(5, 8, 13, 0.62);
border: 1px solid rgba(255, 255, 255, 0.08);
color: rgba(234, 242, 255, 0.72);
font-size: 10px;
font-weight: 600;
letter-spacing: 0.02em;
line-height: 1.25;
text-align: center;
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.75);
backdrop-filter: blur(7px);
}
@media (max-width: 760px) {
#nodeinvaders-ui {
top: 64px;
width: calc(100vw - 96px);
}
.ni-stats {
flex-wrap: wrap;
row-gap: 5px;
}
.ni-hint {
display: none;
}
}
.ni-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background:
radial-gradient(ellipse at 20% 0%, rgba(96, 165, 250, 0.18) 0%, transparent 55%),
radial-gradient(ellipse at 80% 100%, rgba(192, 132, 252, 0.18) 0%, transparent 55%),
linear-gradient(140deg, rgba(6, 10, 18, 0.94), rgba(10, 20, 34, 0.92));
z-index: 5;
overflow: hidden;
}
.ni-overlay::before {
content: "";
position: absolute;
inset: 0;
background-image: repeating-linear-gradient(
0deg,
rgba(255, 255, 255, 0.025) 0,
rgba(255, 255, 255, 0.025) 1px,
transparent 1px,
transparent 3px
);
pointer-events: none;
z-index: 1;
}
.ni-overlay.is-hidden {
display: none;
}
.ni-card {
position: relative;
z-index: 2;
width: 92vw;
max-width: 720px;
padding: 32px 36px;
border-radius: 22px;
border: 1px solid rgba(192, 132, 252, 0.25);
background:
linear-gradient(180deg, rgba(20, 22, 40, 0.95) 0%, rgba(10, 12, 22, 0.95) 100%);
box-shadow:
0 25px 60px rgba(0, 0, 0, 0.6),
0 0 80px rgba(96, 165, 250, 0.15),
inset 0 1px 0 rgba(255, 255, 255, 0.05);
text-align: left;
animation: ni-fade-up 0.6s cubic-bezier(0.2, 0.8, 0.2, 1);
}
.ni-card::before {
content: "";
position: absolute;
inset: -1px;
border-radius: 22px;
padding: 1px;
background: linear-gradient(135deg, rgba(74, 222, 128, 0.4), rgba(96, 165, 250, 0.3), rgba(192, 132, 252, 0.4));
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
opacity: 0.6;
}
.ni-title {
font-family: "Segoe UI Variable Display", "Segoe UI", Inter, ui-sans-serif, system-ui, sans-serif;
font-size: 42px;
font-weight: 900;
line-height: 1;
letter-spacing: 0.08em;
text-transform: uppercase;
margin-bottom: 8px;
background: linear-gradient(120deg, #4ade80 0%, #60a5fa 50%, #c084fc 100%);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
color: transparent;
filter: drop-shadow(0 0 18px rgba(96, 165, 250, 0.45));
}
.ni-subtitle {
font-size: 12px;
letter-spacing: 0.2em;
text-transform: uppercase;
color: rgba(192, 132, 252, 0.85);
margin-bottom: 22px;
}
.ni-section-label {
font-size: 11px;
letter-spacing: 2.4px;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.55);
margin: 6px 0 10px;
}
.ni-modes {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-bottom: 20px;
}
@media (max-width: 600px) {
.ni-modes { grid-template-columns: 1fr; }
}
.ni-mode {
position: relative;
padding: 14px 16px;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.03);
cursor: pointer;
transition: transform 0.18s ease, border-color 0.18s ease, background 0.18s ease, box-shadow 0.18s ease;
text-align: left;
color: inherit;
font-family: inherit;
}
.ni-mode:hover {
transform: translateY(-2px);
border-color: rgba(192, 132, 252, 0.5);
background: rgba(192, 132, 252, 0.06);
}
.ni-mode.is-active {
border-color: rgba(74, 222, 128, 0.7);
background: linear-gradient(135deg, rgba(74, 222, 128, 0.12), rgba(96, 165, 250, 0.08));
box-shadow: 0 0 30px rgba(74, 222, 128, 0.18), inset 0 1px 0 rgba(255, 255, 255, 0.08);
}
.ni-mode-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
margin-bottom: 6px;
}
.ni-mode-name {
font-family: "Segoe UI Variable Display", "Segoe UI", Inter, ui-sans-serif, system-ui, sans-serif;
font-size: 18px;
font-weight: 850;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #eaf2ff;
}
.ni-mode-badge {
font-size: 10px;
letter-spacing: 1.6px;
text-transform: uppercase;
padding: 2px 8px;
border-radius: 999px;
background: rgba(74, 222, 128, 0.18);
border: 1px solid rgba(74, 222, 128, 0.5);
color: #4ade80;
opacity: 0;
transition: opacity 0.15s ease;
}
.ni-mode.is-active .ni-mode-badge {
opacity: 1;
}
.ni-mode-desc {
font-size: 12px;
line-height: 1.45;
color: rgba(234, 242, 255, 0.7);
}
.ni-mode-tag {
margin-top: 8px;
font-size: 10px;
letter-spacing: 1.4px;
text-transform: uppercase;
color: rgba(255, 209, 102, 0.85);
}
.ni-mode[data-mode="api"] .ni-mode-name {
background: linear-gradient(120deg, #f0abfc, #c084fc);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
.ni-mode[data-mode="all"] .ni-mode-name {
background: linear-gradient(120deg, #4ade80, #60a5fa);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
.ni-tip-list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 8px;
margin-bottom: 22px;
font-size: 12px;
color: rgba(234, 242, 255, 0.78);
}
.ni-tip-list span {
display: block;
padding: 8px 12px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.05);
}
.ni-actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
}
.ni-button {
padding: 10px 18px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.15);
background: linear-gradient(135deg, #ff5e5b, #ff9f1c);
color: #0b0f18;
font-weight: bold;
letter-spacing: 1px;
cursor: pointer;
text-transform: uppercase;
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.ni-button:hover {
transform: translateY(-1px);
box-shadow: 0 10px 24px rgba(255, 95, 92, 0.35);
}
.ni-secondary {
background: rgba(255, 255, 255, 0.08);
color: #f0f4ff;
}
.ni-overview {
display: flex;
gap: 12px;
margin: 16px 0 20px;
font-size: 13px;
}
.ni-overview div {
padding: 8px 12px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.08);
min-width: 120px;
}
.ni-overview strong {
display: block;
font-size: 18px;
color: #ffd166;
}
.ni-reason {
font-size: 12px;
text-transform: uppercase;
letter-spacing: 1.4px;
opacity: 0.7;
margin-bottom: 16px;
}
@keyframes ni-fade-up {
0% { opacity: 0; transform: translateY(12px); }
100% { opacity: 1; transform: translateY(0); }
}
#nodeinvaders-skills {
position: absolute;
right: 18px;
top: 50%;
transform: translateY(-34%);
display: flex;
flex-direction: column;
gap: 10px;
z-index: 4;
pointer-events: none;
}
.ni-skill {
display: flex;
flex-direction: column;
align-items: center;
gap: 3px;
width: 74px;
min-height: 74px;
padding: 8px 10px;
border-radius: 10px;
background: rgba(7, 10, 16, 0.9);
border: 1px solid rgba(255, 255, 255, 0.16);
backdrop-filter: blur(8px);
transition: all 0.2s ease;
}
.ni-skill.ready {
border-color: #4ade80;
box-shadow: 0 0 20px rgba(74, 222, 128, 0.4);
animation: ni-pulse 1.5s ease infinite;
}
.ni-skill.active {
border-color: #60a5fa;
box-shadow: 0 0 25px rgba(96, 165, 250, 0.6);
animation: ni-pulse-fast 0.8s ease infinite;
}
.ni-skill-icon {
font-size: 24px;
line-height: 1;
}
.ni-skill-label {
font-size: 10px;
font-weight: bold;
letter-spacing: 0.5px;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.9);
}
.ni-skill-progress {
font-size: 12px;
font-weight: bold;
color: #ffd166;
min-height: 16px;
text-align: center;
}
@media (max-height: 720px) {
#nodeinvaders-skills {
top: auto;
right: 72px;
bottom: 22px;
transform: none;
flex-direction: row;
}
}
.ni-skill.ready .ni-skill-progress {
color: #4ade80;
}
.ni-skill.active .ni-skill-progress {
color: #60a5fa;
}
@keyframes ni-pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.05); }
}
@keyframes ni-pulse-fast {
0%, 100% { transform: scale(1); box-shadow: 0 0 25px rgba(96, 165, 250, 0.6); }
50% { transform: scale(1.08); box-shadow: 0 0 35px rgba(96, 165, 250, 0.8); }
}
@keyframes ni-button-pulse {
0%, 100% { box-shadow: 0 4px 15px rgba(0, 0, 0, 0.5), 0 0 0 0px rgba(96, 165, 250, 0); }
50% { box-shadow: 0 4px 20px rgba(0, 0, 0, 0.6), 0 0 12px 2px rgba(96, 165, 250, 0.25); }
}
#nodeinvaders-toggle-button {
position: fixed;
top: 158px;
right: 8px;
z-index: 10000;
width: 44px;
height: 44px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 12px;
background: rgba(10, 14, 24, 0.85);
color: #eaf2ff;
cursor: pointer;
backdrop-filter: blur(16px) saturate(180%);
border: 1px solid rgba(255, 255, 255, 0.1);
transition: all 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
box-shadow:
0 4px 15px rgba(0, 0, 0, 0.5),
0 0 0 1px rgba(255, 255, 255, 0.05),
inset 0 1px 1px rgba(255, 255, 255, 0.1);
padding: 0;
outline: none;
animation: ni-button-pulse 3s infinite ease-in-out;
}
#nodeinvaders-toggle-button::before {
content: "";
position: absolute;
inset: -1px;
border-radius: 14px;
padding: 1px;
background: linear-gradient(135deg, #4ade80, #60a5fa, #c084fc);
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
opacity: 0.35;
transition: opacity 0.3s ease;
}
#nodeinvaders-toggle-button:hover {
transform: translateY(-2px) scale(1.04);
background: rgba(20, 24, 38, 0.88);
border-color: rgba(255, 255, 255, 0.15);
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.55), 0 0 18px rgba(96, 165, 250, 0.25);
}
#nodeinvaders-toggle-button:hover::before {
opacity: 0.85;
}
#nodeinvaders-toggle-button:active {
transform: translateY(0) scale(0.96);
}
.ni-control-label {
font-family: "Segoe UI Variable Display", "Segoe UI", Inter, system-ui, sans-serif;
font-size: 17px;
font-weight: 900;
letter-spacing: 0.02em;
background: linear-gradient(135deg, #4ade80, #60a5fa, #c084fc);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
filter: drop-shadow(0 0 8px rgba(96, 165, 250, 0.35));
user-select: none;
}
.ni-control-tooltip {
position: absolute;
top: 58px;
right: 0;
background: rgba(8, 10, 16, 0.96);
color: #eaf2ff;
padding: 7px 12px;
border-radius: 9px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
white-space: nowrap;
opacity: 0;
transform: translateY(-8px);
transition: all 0.25s cubic-bezier(0.2, 0.8, 0.2, 1);
pointer-events: none;
border: 1px solid rgba(192, 132, 252, 0.25);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.6);
backdrop-filter: blur(10px);
}
#nodeinvaders-toggle-button:hover .ni-control-tooltip {
opacity: 1;
transform: translateY(0);
}
`;
document.head.appendChild(style);
}
function getCanvasBounds() {
const container = document.getElementById("graph-canvas-container")
|| document.querySelector(".graph-canvas-container")
|| document.querySelector("canvas")?.parentElement;
if (container) {
const rect = container.getBoundingClientRect();
return {
width: rect.width,
height: rect.height,
container,
rect
};
}
return {
width: window.innerWidth,
height: window.innerHeight,
container: null,
rect: null
};
}
function createGamePanel() {
const bounds = getCanvasBounds();
const panel = document.createElement("div");
panel.id = PANEL_ID;
if (bounds.container) {
panel.style.cssText = "position:absolute;left:0;top:0;width:100%;height:100%;display:none;";
} else {
panel.style.cssText = "position:fixed;left:0;top:0;width:100vw;height:100vh;display:none;";
}
panel.innerHTML = `
<div id="nodeinvaders-container">
<canvas id="nodeinvaders-canvas"></canvas>
<div id="nodeinvaders-ui">
<div class="ni-brand">Node Invaders</div>
<div class="ni-mode-pill" id="ni-mode-pill">API Nodes Only</div>
<div class="ni-stats">
<div class="ni-stat">Score <span class="ni-value" id="ni-score">0</span></div>
<div class="ni-stat">Time <span class="ni-value" id="ni-time">0.0</span>s</div>
<div class="ni-stat">Destroyed <span class="ni-value" id="ni-kills">0</span></div>
<div class="ni-stat ni-lives" id="ni-lives" aria-label="Lives"></div>
</div>
<div class="ni-hint">Mouse to aim. Hold click or Space to fire. WASD or arrows to drift. Right click for skills. Esc closes.</div>
</div>
<div id="nodeinvaders-skills">
<div class="ni-skill" id="ni-rocket-skill">
<div class="ni-skill-icon">🚀</div>
<div class="ni-skill-label">Rocket</div>
<div class="ni-skill-progress" id="ni-rocket-progress">10</div>
</div>
<div class="ni-skill" id="ni-laser-skill">
<div class="ni-skill-icon">⚡</div>
<div class="ni-skill-label">Laser</div>
<div class="ni-skill-progress" id="ni-laser-progress">20</div>
</div>
<div class="ni-skill" id="ni-nova-skill">
<div class="ni-skill-icon">✦</div>
<div class="ni-skill-label">Nova</div>
<div class="ni-skill-progress" id="ni-nova-progress">40</div>
</div>
</div>
<div id="nodeinvaders-start" class="ni-overlay">
<div class="ni-card">
<div class="ni-title">Node Invaders</div>
<div class="ni-subtitle">ComfyUI Arcade Shooter</div>
<div class="ni-section-label">Choose Your Battlefield</div>
<div class="ni-modes">
<button type="button" class="ni-mode is-active" data-mode="api">
<div class="ni-mode-head">
<span class="ni-mode-name">API Invasion</span>
<span class="ni-mode-badge">Default</span>
</div>
<div class="ni-mode-desc">Only <strong>api/*</strong> nodes spawn. Cleaner pool, steadier pacing.</div>
<div class="ni-mode-tag">Recommended · Spicy</div>
</button>
<button type="button" class="ni-mode" data-mode="all">
<div class="ni-mode-head">
<span class="ni-mode-name">Total Chaos</span>
<span class="ni-mode-badge">Hard</span>
</div>
<div class="ni-mode-desc">A wider node pool with faster spawns, more pressure, and earlier bosses.</div>
<div class="ni-mode-tag">Hard mode</div>
</button>
</div>
<div class="ni-tip-list">
<span>🎯 Mouse aims, click / Space fires.</span>
<span>🛞 WASD or arrows to drift.</span>
<span>💥 Right-click triggers skills when ready.</span>
<span>👹 Beware the <strong>Boss</strong> — it bites back.</span>
</div>
<div class="ni-actions">
<button class="ni-button" id="ni-start-btn">Launch Run</button>
<button class="ni-button ni-secondary" id="ni-close-btn">Close</button>
</div>
</div>
</div>
<div id="nodeinvaders-over" class="ni-overlay is-hidden">
<div class="ni-card">
<div class="ni-title">Run Over</div>
<div class="ni-subtitle">Can you feel it?</div>
<div class="ni-reason" id="ni-over-reason">Enemy impact</div>
<div class="ni-overview">
<div><strong id="ni-over-score">0</strong>Score</div>
<div><strong id="ni-over-time">0.0s</strong>Time</div>
<div><strong id="ni-over-kills">0</strong>Destroyed</div>
</div>
<div class="ni-actions">
<button class="ni-button" id="ni-restart-btn">Restart</button>
<button class="ni-button ni-secondary" id="ni-exit-btn">Close</button>
</div>
</div>
</div>
</div>
`;
if (bounds.container) {
const style = window.getComputedStyle(bounds.container);
if (style.position === "static") {
bounds.container.style.position = "relative";
}
bounds.container.appendChild(panel);
} else {
document.body.appendChild(panel);
}
gamePanel = panel;
const root = panel.querySelector("#nodeinvaders-container");
gameInstance = new NodeInvadersGame(root);
gameInstance.setToggleGamePanel(toggleGamePanel);
gameInstance.mount();
return panel;
}
function createControlButton() {
if (document.getElementById(BUTTON_ID)) {
gameButton = document.getElementById(BUTTON_ID);
return gameButton;
}
const button = document.createElement("button");
button.id = BUTTON_ID;
button.type = "button";
const label = document.createElement("span");
label.className = "ni-control-label";
label.textContent = "NI";
button.appendChild(label);
const tooltip = document.createElement("div");
tooltip.className = "ni-control-tooltip";
tooltip.textContent = "Node Invaders";
button.appendChild(tooltip);
button.addEventListener("click", toggleGamePanel);
document.body.appendChild(button);
gameButton = button;
return button;
}
async function toggleGamePanel() {
if (!gamePanel) {
createGamePanel();
}
const isVisible = gamePanel.style.display !== "none";
if (!isVisible) {
gamePanel.style.display = "block";
if (gameButton) {
const label = gameButton.querySelector(".ni-control-label");
if (label) label.textContent = "X";
const tooltip = gameButton.querySelector(".ni-control-tooltip");
if (tooltip) {
tooltip.textContent = "Close Node Invaders";
}
}
gameInstance?.setVisible(true);
gameInstance?.resize();
} else {
gamePanel.style.display = "none";
if (gameButton) {
const label = gameButton.querySelector(".ni-control-label");
if (label) label.textContent = "NI";
const tooltip = gameButton.querySelector(".ni-control-tooltip");
if (tooltip) {
tooltip.textContent = "Node Invaders";
}
}
gameInstance?.setVisible(false);
}
}
app.registerExtension({
name: EXTENSION_NAME,
async setup() {
injectStyles();
createControlButton();
resizeHandler = () => {
if (gameInstance) {
gameInstance.resize();
}
};
window.addEventListener("resize", resizeHandler);
},
async beforeUnload() {
if (resizeHandler) {
window.removeEventListener("resize", resizeHandler);
resizeHandler = null;
}
if (gameInstance) {
gameInstance.setVisible(false);
gameInstance.detachEvents();
gameInstance = null;
}
if (gamePanel) {
gamePanel.remove();
gamePanel = null;
}
if (gameButton) {
gameButton.remove();
gameButton = null;
}
const style = document.getElementById(STYLE_ID);
if (style) {
style.remove();
}
}
});