(() => { const create = () => { const sceneEl = document.getElementById("scene-wrapper"); const floatingMessagesEl = document.getElementById("floating-messages"); const activeColorEl = document.getElementById("active-color"); const chainLenEl = document.getElementById("chain-length"); const spawnRateEl = document.getElementById("spawn-rate"); const minLinkEl = document.getElementById("min-link"); const paletteLegendEl = document.getElementById("palette-legend"); const scoreEl = document.getElementById("score"); const highScoreEl = document.getElementById("high-score"); const sceneSelectEl = document.getElementById("scene-select"); const gameOverEl = document.getElementById("game-over"); const finalScoreEl = document.getElementById("final-score"); const restartBtn = document.getElementById("restart-btn"); const pauseBtn = document.getElementById("pause-btn"); const pauseOverlay = document.getElementById("pause-overlay"); const goalLabelEl = document.getElementById("goal-label"); const goalProgressEl = document.getElementById("goal-progress"); const winEl = document.getElementById("win-overlay"); const winMessageEl = document.getElementById("win-message"); const winNextBtn = document.getElementById("win-next"); const winRestartBtn = document.getElementById("win-restart"); const clearFxEl = document.createElement("div"); clearFxEl.className = "clear-effects"; if (sceneEl) { const firstChild = sceneEl.firstChild; sceneEl.insertBefore(clearFxEl, firstChild || null); } let messageDefaults = { durationMs: 4200, position: { xPercent: 50, yPercent: 12 }, }; const handlers = { onPauseToggle: null, onRestart: null, onSceneChange: null, onWinNext: null, }; if (pauseBtn) { pauseBtn.addEventListener("click", () => { if (handlers.onPauseToggle) handlers.onPauseToggle(); }); } if (restartBtn) { restartBtn.addEventListener("click", () => { if (handlers.onRestart) handlers.onRestart(); }); } if (sceneSelectEl) { sceneSelectEl.addEventListener("change", (e) => { if (handlers.onSceneChange) handlers.onSceneChange(e.target.value); }); } if (winNextBtn) { winNextBtn.addEventListener("click", () => { if (handlers.onWinNext) handlers.onWinNext(); }); } if (winRestartBtn) { winRestartBtn.addEventListener("click", () => { if (handlers.onRestart) handlers.onRestart(); }); } window.addEventListener("keydown", (e) => { if (e.key === "Escape" && handlers.onPauseToggle) { handlers.onPauseToggle(); } }); const setHandlers = (nextHandlers = {}) => { Object.assign(handlers, nextHandlers); }; const setSceneOptions = (scenes, activeId) => { if (!sceneSelectEl) return; sceneSelectEl.innerHTML = ""; scenes.forEach((scene) => { const opt = document.createElement("option"); opt.value = scene.id; opt.textContent = scene.name; sceneSelectEl.appendChild(opt); }); if (activeId) { sceneSelectEl.value = activeId; } }; const setSceneSelection = (sceneId) => { if (sceneSelectEl) { sceneSelectEl.value = sceneId; } }; const setPauseState = (paused) => { if (pauseOverlay) { pauseOverlay.classList.toggle("visible", paused); } if (pauseBtn) { pauseBtn.textContent = paused ? "Resume" : "Pause"; } }; const setGoal = ({ label, progress, colors }, effects = {}) => { if (goalLabelEl) { goalLabelEl.innerHTML = ""; if (Array.isArray(colors) && colors.length > 0) { colors.forEach((color) => { const swatch = document.createElement("span"); swatch.style.background = color; swatch.style.display = "inline-block"; swatch.style.width = "14px"; swatch.style.height = "14px"; swatch.style.borderRadius = "50%"; swatch.style.border = "1px solid rgba(255,255,255,0.2)"; swatch.style.marginRight = "6px"; goalLabelEl.appendChild(swatch); }); const text = document.createElement("span"); text.textContent = label || ""; goalLabelEl.appendChild(text); } else { goalLabelEl.textContent = label || "—"; } } if (goalProgressEl) goalProgressEl.style.width = `${Math.max( 0, Math.min(100, progress ?? 0), )}%`; if (goalProgressEl) { const parentProgress = goalProgressEl.parentElement; const targetEls = [goalProgressEl, parentProgress].filter(Boolean); targetEls.forEach((el) => { el.classList.remove("goal-near", "goal-complete", "goal-gradient"); }); goalProgressEl.style.removeProperty("background-image"); goalProgressEl.style.removeProperty("--goal-glow-alpha"); const fraction = Math.max(0, Math.min(1, (progress ?? 0) / 100)); if (effects?.enabled) { const near = effects.nearThreshold ?? 0.7; const complete = effects.completeThreshold ?? 0.98; const pulseSpeed = Math.max(200, effects.pulseSpeedMs ?? 900); goalProgressEl.style.setProperty( "--goal-pulse-speed", `${pulseSpeed}ms`, ); goalProgressEl.style.setProperty( "--goal-glow-alpha", effects.glowStrength ?? 0.55, ); if (fraction >= complete) { targetEls.forEach((el) => el.classList.add("goal-complete")); } else if (fraction >= near) { targetEls.forEach((el) => el.classList.add("goal-near")); } const useGradient = effects.gradientBlend !== false && Array.isArray(colors) && colors.length > 0; if (useGradient) { goalProgressEl.classList.add("goal-gradient"); const denom = Math.max(1, colors.length - 1); const stops = colors .map((c, idx) => `${c} ${(idx / denom) * 100}%`) .join(", "); goalProgressEl.style.backgroundImage = `linear-gradient(90deg, ${stops})`; } } } }; const showWin = (message) => { if (winMessageEl) winMessageEl.textContent = message || "You win!"; if (winEl) winEl.classList.add("visible"); }; const hideWin = () => { if (winEl) winEl.classList.remove("visible"); }; const showGameOver = (score) => { if (finalScoreEl) finalScoreEl.textContent = score; if (gameOverEl) gameOverEl.classList.add("visible"); }; const hideGameOver = () => { if (gameOverEl) gameOverEl.classList.remove("visible"); }; const spawnScorePopup = (point, amount, color) => { if (!point || !sceneEl) return; const el = document.createElement("div"); el.className = "floating-score"; const sign = amount > 0 ? "+" : ""; el.textContent = `${sign}${amount}`; el.style.left = `${point.x}px`; el.style.top = `${point.y}px`; el.style.color = color || "#e0f2fe"; sceneEl.appendChild(el); setTimeout(() => el.remove(), 950); }; const updateHud = ({ spawnIntervalMs, minChain, chainLength, score, highScore, activeColor, }) => { if (spawnRateEl) spawnRateEl.textContent = `${spawnIntervalMs} ms`; if (minLinkEl) minLinkEl.textContent = minChain; if (chainLenEl) chainLenEl.textContent = chainLength; if (scoreEl) scoreEl.textContent = score; if (highScoreEl) highScoreEl.textContent = highScore; if (activeColorEl) { if (activeColor) { activeColorEl.textContent = ""; activeColorEl.style.display = "inline-block"; activeColorEl.style.width = "14px"; activeColorEl.style.height = "14px"; activeColorEl.style.borderRadius = "50%"; activeColorEl.style.background = activeColor; activeColorEl.style.border = "1px solid rgba(255,255,255,0.3)"; } else { activeColorEl.removeAttribute("style"); activeColorEl.textContent = "—"; } } }; const buildLegend = (palette) => { if (!paletteLegendEl) return; paletteLegendEl.innerHTML = ""; palette.forEach((color) => { const swatch = document.createElement("span"); swatch.style.background = color; paletteLegendEl.appendChild(swatch); }); }; const setMessageDefaults = (overrides = {}) => { messageDefaults = { ...messageDefaults, ...overrides, position: { ...messageDefaults.position, ...(overrides.position || {}), }, }; }; const renderFloatingMessage = (el, text, colors) => { el.innerHTML = ""; if (Array.isArray(colors) && colors.length > 0) { colors.forEach((color) => { const swatch = document.createElement("span"); swatch.style.background = color; swatch.style.display = "inline-block"; swatch.style.width = "14px"; swatch.style.height = "14px"; swatch.style.borderRadius = "50%"; swatch.style.border = "1px solid rgba(255,255,255,0.2)"; swatch.style.marginRight = "6px"; el.appendChild(swatch); }); } const textSpan = document.createElement("span"); textSpan.textContent = text; el.appendChild(textSpan); }; const activeMessages = []; const clearMessages = () => { if (!floatingMessagesEl) return; activeMessages.length = 0; floatingMessagesEl.innerHTML = ""; }; const showFloatingMessage = (message, options = {}) => { if (!floatingMessagesEl) return; const msgObj = typeof message === "string" ? { text: message } : message || {}; const text = (msgObj.text || "").trim(); if (!text) return; const colors = Array.isArray(msgObj.colors) ? msgObj.colors : null; const durationMs = Number.isFinite(options.durationMs) ? options.durationMs : messageDefaults.durationMs; const position = { ...messageDefaults.position, ...(options.position || {}), }; const el = document.createElement("div"); el.className = "floating-message"; renderFloatingMessage(el, text, colors); el.style.left = `${position.xPercent ?? 50}%`; const verticalOffset = activeMessages.length * 34; el.style.top = `calc(${position.yPercent ?? 10}% + ${verticalOffset}px)`; floatingMessagesEl.appendChild(el); requestAnimationFrame(() => { el.classList.add("visible"); }); setTimeout(() => { el.classList.remove("visible"); setTimeout(() => { el.remove(); const idx = activeMessages.indexOf(el); if (idx >= 0) activeMessages.splice(idx, 1); }, 260); }, durationMs); activeMessages.push(el); }; const createPopEffect = (items, options) => { const { durationMs = 320, startScale = 1, endScale = 1.8, startOpacity = 0.95, endOpacity = 0, sizeScale = 1, } = options; items.forEach((item) => { if (!item) return; const size = Math.max(4, (item.radius || 18) * 2 * sizeScale); const el = document.createElement("div"); el.className = "clear-effect clear-effect--pop"; el.style.width = `${size}px`; el.style.height = `${size}px`; el.style.left = `${item.x}px`; el.style.top = `${item.y}px`; el.style.background = item.color || "#fff"; el.style.opacity = startOpacity; el.style.transform = `translate(-50%, -50%) scale(${startScale})`; el.style.borderRadius = item.shape === "rect" ? "18%" : "50%"; clearFxEl.appendChild(el); requestAnimationFrame(() => { el.style.transition = `transform ${durationMs}ms ease-out, opacity ${durationMs}ms ease-out`; el.style.transform = `translate(-50%, -50%) scale(${endScale})`; el.style.opacity = endOpacity; }); setTimeout(() => el.remove(), durationMs + 80); }); }; const createShatterEffect = (items, options) => { const { durationMs = 360, sizeScale = 1, shardCount = 6, startOpacity = 0.94, } = options; items.forEach((item) => { if (!item) return; const base = Math.max(8, (item.radius || 18) * sizeScale); for (let i = 0; i < shardCount; i += 1) { const piece = document.createElement("div"); piece.className = "clear-effect clear-effect--shatter"; piece.style.width = `${base * 0.6}px`; piece.style.height = `${base * 0.6}px`; piece.style.left = `${item.x}px`; piece.style.top = `${item.y}px`; piece.style.background = item.color || "#38bdf8"; piece.style.opacity = startOpacity; piece.style.borderRadius = "14%"; piece.style.transform = "translate(-50%, -50%) scale(0.9) rotate(0deg)"; clearFxEl.appendChild(piece); const spread = base * 1.5; const dx = (Math.random() - 0.5) * spread; const dy = (Math.random() - 0.3) * spread; const rot = (Math.random() - 0.5) * 260; const scale = 0.9 + Math.random() * 0.5; requestAnimationFrame(() => { piece.style.transition = `transform ${durationMs}ms ease-out, opacity ${durationMs}ms ease-out`; piece.style.transform = `translate(${dx}px, ${dy}px) scale(${scale}) rotate(${rot}deg)`; piece.style.opacity = "0"; }); setTimeout(() => piece.remove(), durationMs + 80); } }); }; const spawnClearEffects = (items = [], options = {}) => { if (!clearFxEl || !Array.isArray(items) || items.length === 0) return; const { type = "pop", durationMs = 320, startScale = 1, endScale = 1.8, startOpacity = 0.95, endOpacity = 0, sizeScale = 1, shardCount = 6, } = options; if (type === "shatter") { createShatterEffect(items, { durationMs, sizeScale, shardCount, startOpacity, }); } else { createPopEffect(items, { durationMs, startScale, endScale, startOpacity, endOpacity, sizeScale, }); } }; const hexToRgb = (hex) => { if (typeof hex !== "string") return null; const match = hex .trim() .toLowerCase() .match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/); if (!match) return null; let value = match[1]; if (value.length === 3) { value = value .split("") .map((c) => c + c) .join(""); } const num = parseInt(value, 16); return { r: (num >> 16) & 255, g: (num >> 8) & 255, b: num & 255, }; }; const deriveOppositeColors = (palette = []) => { const alphas = [0.4, 0.32, 0.24]; return palette .slice(0, 3) .map((c, idx) => { const rgb = hexToRgb(c); if (!rgb) return null; const opp = { r: 255 - rgb.r, g: 255 - rgb.g, b: 255 - rgb.b, }; return `rgba(${opp.r}, ${opp.g}, ${opp.b}, ${alphas[idx] ?? 0.1})`; }) .filter(Boolean); }; const setBackdrop = (backdrop = {}, palette = []) => { if (!sceneEl) return; const derived = Array.isArray(backdrop.colors) && backdrop.colors.length > 0 ? backdrop.colors : deriveOppositeColors(palette); const colors = derived && derived.length > 0 ? derived : [ "rgba(56, 189, 248, 0.08)", "rgba(167, 139, 250, 0.07)", "rgba(52, 211, 153, 0.06)", ]; if (backdrop.enabled === false) { sceneEl.style.removeProperty("--backdrop-opacity"); sceneEl.style.removeProperty("--backdrop-blur"); sceneEl.style.removeProperty("--backdrop-speed"); sceneEl.style.removeProperty("--backdrop-color-a"); sceneEl.style.removeProperty("--backdrop-color-b"); sceneEl.style.removeProperty("--backdrop-color-c"); sceneEl.classList.remove("backdrop-enabled"); return; } const [ c1 = "rgba(56, 189, 248, 0.16)", c2 = "rgba(167, 139, 250, 0.14)", c3 = "rgba(52, 211, 153, 0.12)", ] = colors; sceneEl.style.setProperty( "--backdrop-opacity", `${backdrop.opacity ?? 0.24}`, ); sceneEl.style.setProperty("--backdrop-blur", `${backdrop.blur ?? 24}px`); const speed = Math.max(8, backdrop.speedSec ?? 30); sceneEl.style.setProperty("--backdrop-speed", `${speed}s`); sceneEl.style.setProperty("--backdrop-color-a", c1); sceneEl.style.setProperty("--backdrop-color-b", c2); sceneEl.style.setProperty("--backdrop-color-c", c3); sceneEl.classList.add("backdrop-enabled"); }; const fpsEl = document.createElement("div"); fpsEl.className = "fps-counter"; fpsEl.textContent = "FPS: —"; if (sceneEl) sceneEl.appendChild(fpsEl); let fpsVisible = false; const setFpsVisibility = (on) => { fpsVisible = !!on; fpsEl.classList.toggle("visible", fpsVisible); }; let lastFpsUpdate = 0; let frameCount = 0; const updateFps = () => { if (!fpsVisible) return; frameCount += 1; const now = performance.now(); if (now - lastFpsUpdate >= 500) { const fps = Math.round((frameCount * 1000) / (now - lastFpsUpdate)); fpsEl.textContent = `FPS: ${fps}`; frameCount = 0; lastFpsUpdate = now; } }; const api = { sceneEl, updateHud, buildLegend, spawnScorePopup, setPauseState, showGameOver, hideGameOver, showWin, hideWin, setSceneOptions, setSceneSelection, setHandlers, setGoal, showFloatingMessage, setMessageDefaults, clearMessages, spawnClearEffects, setBackdrop, setFpsVisibility, updateFps, }; return api; }; window.PhysilinksUI = { create: (...args) => { const instance = create(...args); window.PhysilinksUI.instance = instance; return instance; }, instance: null, }; })();